diff --git a/CHANGELOG.md b/CHANGELOG.md index c3d788c..fcbf621 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/bookbridge-rust-core/src/routes/account.rs b/bookbridge-rust-core/src/routes/account.rs index 52bb977..e55654f 100644 --- a/bookbridge-rust-core/src/routes/account.rs +++ b/bookbridge-rust-core/src/routes/account.rs @@ -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 \ @@ -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 { diff --git a/bookbridge-rust-core/src/routes/admin.rs b/bookbridge-rust-core/src/routes/admin.rs index c858be0..ef73b06 100644 --- a/bookbridge-rust-core/src/routes/admin.rs +++ b/bookbridge-rust-core/src/routes/admin.rs @@ -108,6 +108,7 @@ pub fn admin_routes() -> Router { 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 { diff --git a/bookbridge-rust-core/src/routes/mod.rs b/bookbridge-rust-core/src/routes/mod.rs index 4fb9253..5f3277a 100644 --- a/bookbridge-rust-core/src/routes/mod.rs +++ b/bookbridge-rust-core/src/routes/mod.rs @@ -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; diff --git a/bookbridge-rust-core/src/routes/reports.rs b/bookbridge-rust-core/src/routes/reports.rs new file mode 100644 index 0000000..63d6b02 --- /dev/null +++ b/bookbridge-rust-core/src/routes/reports.rs @@ -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, + pub created_at: DateTime, + pub reporter_name: Option, + pub listing_id: Option, + pub listing_title: Option, + pub listing_status: Option, + pub reported_user_id: Option, + pub reported_user_name: Option, +} + +#[derive(Serialize)] +pub struct ReportList { + pub reports: Vec, +} + +pub fn report_routes() -> Router { + 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, + _admin: AdminUser, +) -> Result, 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, + AdminUser(admin_id): AdminUser, + Path(report_id): Path, + Json(request): Json, +) -> Result, 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, ¬e).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, + AdminUser(admin_id): AdminUser, + Path(report_id): Path, + Json(request): Json, +) -> Result, 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, + ¬e, + ) + .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, 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 { + 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(()) +} diff --git a/bookbridge-rust-core/tests/account_tests.rs b/bookbridge-rust-core/tests/account_tests.rs index 4ba2e88..f197a6c 100644 --- a/bookbridge-rust-core/tests/account_tests.rs +++ b/bookbridge-rust-core/tests/account_tests.rs @@ -420,6 +420,28 @@ async fn world() -> Option { .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(); @@ -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", ] { @@ -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( diff --git a/bookbridge-rust-core/tests/admin_tests.rs b/bookbridge-rust-core/tests/admin_tests.rs index 9296b1e..83dfd32 100644 --- a/bookbridge-rust-core/tests/admin_tests.rs +++ b/bookbridge-rust-core/tests/admin_tests.rs @@ -294,7 +294,7 @@ fn refund_payout_decision() { // ------------------------------------- auth gate (no database required) -const ROUTES: [(&str, &str); 7] = [ +const ROUTES: [(&str, &str); 10] = [ ("GET", "/admin/me"), ("GET", "/admin/disputes"), ( @@ -314,6 +314,15 @@ const ROUTES: [(&str, &str); 7] = [ "POST", "/admin/unmatched-payments/00000000-0000-0000-0000-000000000001/dismiss", ), + ("GET", "/admin/reports"), + ( + "POST", + "/admin/reports/00000000-0000-0000-0000-000000000001/dismiss", + ), + ( + "POST", + "/admin/reports/00000000-0000-0000-0000-000000000001/remove-listing", + ), ]; async fn unreachable_db_state() -> AppState { @@ -1133,3 +1142,237 @@ async fn young_seller_is_paid_on_the_guardian_number() { assert_eq!(f.payouts.len(), 1); assert_eq!(f.payouts[0]["phone"], "699000111"); } + +// --------------------------------------------------------------- reports + +/// A listing by a new seller with two open reports about it (one from +/// `w.user`, one from another reporter) and an unrelated user report. +struct Reported { + seller: Uuid, + listing: Uuid, + report: Uuid, + second_report: Uuid, + user_report: Uuid, +} + +async fn seed_reports(w: &World, listing_status: &str) -> Reported { + let (seller, other) = (Uuid::new_v4(), Uuid::new_v4()); + sqlx::query("INSERT INTO auth.users (id) VALUES ($1), ($2)") + .bind(seller) + .bind(other) + .execute(&w.pool) + .await + .unwrap(); + sqlx::query( + "INSERT INTO profiles (id, full_name) VALUES ($1, 'Shady Seller'), ($2, 'Other'), \ + ($3, 'Reporter') ON CONFLICT (id) DO UPDATE SET full_name = EXCLUDED.full_name", + ) + .bind(seller) + .bind(other) + .bind(w.user) + .execute(&w.pool) + .await + .unwrap(); + let listing: Uuid = sqlx::query_scalar( + "INSERT INTO listings (title, author, price_fcfa, condition, seller_id, status) \ + VALUES ('Fake Book', 'Author', 500, 'good', $1, $2) RETURNING id", + ) + .bind(seller) + .bind(listing_status) + .fetch_one(&w.pool) + .await + .unwrap(); + let report = |reporter: Uuid, listing: Option, user: Option, reason: &str| { + sqlx::query_scalar::<_, Uuid>( + "INSERT INTO content_reports (reporter_id, listing_id, reported_user_id, reason, details) \ + VALUES ($1, $2, $3, $4, 'Looks fake') RETURNING id", + ) + .bind(reporter) + .bind(listing) + .bind(user) + .bind(reason.to_string()) + .fetch_one(&w.pool) + }; + let first = report(w.user, Some(listing), None, "scam").await.unwrap(); + let second = report(other, Some(listing), None, "spam").await.unwrap(); + let user_report = report(w.user, None, Some(other), "harassment") + .await + .unwrap(); + Reported { + seller, + listing, + report: first, + second_report: second, + user_report, + } +} + +async fn report_status(w: &World, id: Uuid) -> (String, Option) { + let row = sqlx::query("SELECT status, reviewed_by FROM content_reports WHERE id = $1") + .bind(id) + .fetch_one(&w.pool) + .await + .unwrap(); + (row.get("status"), row.get("reviewed_by")) +} + +async fn listing_status(w: &World, id: Uuid) -> String { + sqlx::query_scalar("SELECT status FROM listings WHERE id = $1") + .bind(id) + .fetch_one(&w.pool) + .await + .unwrap() +} + +async fn report_actions(w: &World, report: Uuid) -> Vec<(String, Uuid)> { + sqlx::query("SELECT action, admin_id FROM admin_actions WHERE report_id = $1") + .bind(report) + .fetch_all(&w.pool) + .await + .unwrap() + .into_iter() + .map(|r| (r.get("action"), r.get("admin_id"))) + .collect() +} + +#[tokio::test] +async fn admin_lists_open_reports_with_names() { + let Some(w) = world().await else { return }; + let r = seed_reports(&w, "available").await; + + let (status, body) = send( + &w.state, + Method::GET, + "/admin/reports", + Some(ADMIN_TOKEN), + None, + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + let listed = body["reports"] + .as_array() + .unwrap() + .iter() + .find(|x| x["id"] == json!(r.report)) + .expect("open report is listed") + .clone(); + assert_eq!(listed["reason"], "scam"); + assert_eq!(listed["details"], "Looks fake"); + assert_eq!(listed["reporter_name"], "Reporter"); + assert_eq!(listed["listing_title"], "Fake Book"); + assert_eq!(listed["listing_status"], "available"); + // A listing report names the seller as the reported user. + assert_eq!(listed["reported_user_id"], json!(r.seller)); + assert_eq!(listed["reported_user_name"], "Shady Seller"); +} + +#[tokio::test] +async fn removing_a_reported_listing_closes_every_report_about_it() { + let Some(w) = world().await else { return }; + let r = seed_reports(&w, "available").await; + + let path = format!("/admin/reports/{}/remove-listing", r.report); + let (status, body) = send(&w.state, Method::POST, &path, Some(ADMIN_TOKEN), note()).await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(body["status"], "actioned"); + assert_eq!(body["closed_reports"], 2); + + assert_eq!(listing_status(&w, r.listing).await, "removed"); + assert_eq!( + report_status(&w, r.report).await, + ("actioned".into(), Some(w.admin)) + ); + assert_eq!( + report_status(&w, r.second_report).await, + ("actioned".into(), Some(w.admin)) + ); + assert_eq!( + report_status(&w, r.user_report).await, + ("open".into(), None) + ); + assert_eq!( + report_actions(&w, r.report).await, + vec![("remove_reported_listing".into(), w.admin)] + ); + + // Already closed: a second decision is refused. + let (status, _) = send(&w.state, Method::POST, &path, Some(ADMIN_TOKEN), note()).await; + assert_eq!(status, StatusCode::CONFLICT); +} + +#[tokio::test] +async fn a_sold_listing_stays_sold_when_removed() { + let Some(w) = world().await else { return }; + let r = seed_reports(&w, "sold").await; + + let path = format!("/admin/reports/{}/remove-listing", r.report); + let (status, body) = send(&w.state, Method::POST, &path, Some(ADMIN_TOKEN), note()).await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(listing_status(&w, r.listing).await, "sold"); +} + +#[tokio::test] +async fn dismissing_closes_only_that_report() { + let Some(w) = world().await else { return }; + let r = seed_reports(&w, "available").await; + + let path = format!("/admin/reports/{}/dismiss", r.report); + let (status, body) = send(&w.state, Method::POST, &path, Some(ADMIN_TOKEN), note()).await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(body["closed_reports"], 1); + + assert_eq!(listing_status(&w, r.listing).await, "available"); + assert_eq!( + report_status(&w, r.report).await, + ("dismissed".into(), Some(w.admin)) + ); + assert_eq!( + report_status(&w, r.second_report).await, + ("open".into(), None) + ); + assert_eq!( + report_actions(&w, r.report).await, + vec![("dismiss_report".into(), w.admin)] + ); +} + +#[tokio::test] +async fn a_user_report_cannot_remove_a_listing() { + let Some(w) = world().await else { return }; + let r = seed_reports(&w, "available").await; + + let path = format!("/admin/reports/{}/remove-listing", r.user_report); + let (status, _) = send(&w.state, Method::POST, &path, Some(ADMIN_TOKEN), note()).await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!( + report_status(&w, r.user_report).await, + ("open".into(), None) + ); + + let missing = format!("/admin/reports/{}/dismiss", Uuid::new_v4()); + let (status, _) = send(&w.state, Method::POST, &missing, Some(ADMIN_TOKEN), note()).await; + assert_eq!(status, StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn report_review_needs_an_admin_and_a_note() { + let Some(w) = world().await else { return }; + let r = seed_reports(&w, "available").await; + + let path = format!("/admin/reports/{}/remove-listing", r.report); + let (status, _) = send(&w.state, Method::POST, &path, Some(USER_TOKEN), note()).await; + assert_eq!(status, StatusCode::FORBIDDEN); + let (status, _) = send( + &w.state, + Method::POST, + &path, + Some(ADMIN_TOKEN), + Some(json!({ "note": " " })), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + + assert_eq!(listing_status(&w, r.listing).await, "available"); + assert_eq!(report_status(&w, r.report).await, ("open".into(), None)); + assert!(report_actions(&w, r.report).await.is_empty()); +} diff --git a/lib/config/router.dart b/lib/config/router.dart index 7a76bbe..40f2b8f 100644 --- a/lib/config/router.dart +++ b/lib/config/router.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import 'package:book_bridge/features/moderation/presentation/screens/blocked_users_screen.dart'; import 'package:provider/provider.dart'; import 'package:book_bridge/features/auth/presentation/screens/sign_in_screen.dart'; import 'package:book_bridge/features/auth/presentation/screens/sign_up_screen.dart'; @@ -281,6 +282,11 @@ final appRouter = GoRouter( name: 'about', builder: (context, state) => const AboutScreen(), ), + GoRoute( + path: '/blocked-users', + name: 'blocked-users', + builder: (context, state) => const BlockedUsersScreen(), + ), // Favorites Route GoRoute( path: '/favorites', diff --git a/lib/core/error/exceptions.dart b/lib/core/error/exceptions.dart index bcbc2c2..ac1050d 100644 --- a/lib/core/error/exceptions.dart +++ b/lib/core/error/exceptions.dart @@ -29,6 +29,12 @@ class ConflictException extends ServerException { ConflictException({required super.message}); } +/// Exception thrown when a message is refused because one of the two users +/// has blocked the other. +class MessagingBlockedException extends ServerException { + MessagingBlockedException({required super.message}); +} + /// Exception thrown on generic/unexpected errors. class UnknownException extends AppException { UnknownException({required super.message}); diff --git a/lib/core/presentation/widgets/scaffold_with_navbar.dart b/lib/core/presentation/widgets/scaffold_with_navbar.dart index d47c15d..dfd9325 100644 --- a/lib/core/presentation/widgets/scaffold_with_navbar.dart +++ b/lib/core/presentation/widgets/scaffold_with_navbar.dart @@ -1,6 +1,8 @@ import 'dart:async'; import 'package:book_bridge/features/auth/presentation/viewmodels/auth_viewmodel.dart'; import 'package:book_bridge/features/chat/presentation/viewmodels/chat_viewmodel.dart'; +import 'package:book_bridge/features/listings/presentation/viewmodels/home_viewmodel.dart'; +import 'package:book_bridge/features/moderation/presentation/viewmodels/moderation_viewmodel.dart'; import 'package:book_bridge/features/notifications/presentation/viewmodels/notifications_viewmodel.dart'; import 'package:book_bridge/features/transactions/presentation/viewmodels/pending_orders_viewmodel.dart'; import 'package:flutter/material.dart'; @@ -19,20 +21,26 @@ class ScaffoldWithNavBar extends StatefulWidget { class _ScaffoldWithNavBarState extends State { Timer? _badgeRefreshTimer; Timer? _ordersRefreshTimer; + late final ModerationViewModel _moderation; @override void initState() { super.initState(); + _moderation = context.read(); // Initialize notifications globally WidgetsBinding.instance.addPostFrameCallback((_) { context.read().subscribeToNotifications(); // Initial load of conversations for the badge count context.read().refreshConversationsSilently(); _refreshPendingOrders(); + _refreshBlockedUsers(); }); // Escrow orders change less often than chats, so poll them more slowly. _ordersRefreshTimer = Timer.periodic(const Duration(minutes: 2), (_) { - if (mounted) _refreshPendingOrders(); + if (mounted) { + _refreshPendingOrders(); + _refreshBlockedUsers(); + } }); // Refresh unread badge count every 30 seconds _badgeRefreshTimer = Timer.periodic(const Duration(seconds: 30), (_) { @@ -46,9 +54,21 @@ class _ScaffoldWithNavBarState extends State { void dispose() { _badgeRefreshTimer?.cancel(); _ordersRefreshTimer?.cancel(); + // The shell closes on sign-out; don't carry blocks to the next account. + _moderation.reset(); super.dispose(); } + /// Blocks made on another device show up here; refresh the feed and chats + /// when they change so blocked users disappear. + Future _refreshBlockedUsers() async { + final userId = context.read().currentUser?.id; + final changed = await _moderation.load(userId); + if (!changed || !mounted) return; + context.read().refreshListings(); + context.read().refreshConversationsSilently(); + } + void _refreshPendingOrders() { final userId = context.read().currentUser?.id; context.read().refresh(userId); diff --git a/lib/features/admin/data/datasources/rust_admin_data_source.dart b/lib/features/admin/data/datasources/rust_admin_data_source.dart index 30a5bb4..828f3a2 100644 --- a/lib/features/admin/data/datasources/rust_admin_data_source.dart +++ b/lib/features/admin/data/datasources/rust_admin_data_source.dart @@ -105,6 +105,30 @@ class RustAdminDataSource { ); } + Future> reports() async { + final body = await _client.get( + '/admin/reports', + failurePrefix: 'Could not load reports', + ); + return _list(body, 'reports').map(_report).toList(); + } + + Future dismissReport(String reportId, String note) { + return _resolve( + '/admin/reports/${Uri.encodeComponent(reportId)}/dismiss', + note, + failurePrefix: 'Dismiss failed', + ); + } + + Future removeReportedListing(String reportId, String note) { + return _resolve( + '/admin/reports/${Uri.encodeComponent(reportId)}/remove-listing', + note, + failurePrefix: 'Remove failed', + ); + } + Future idPhotoUrl(String path) async { final sign = _signIdPhoto; if (sign == null) { @@ -190,6 +214,26 @@ class RustAdminDataSource { ); } + static ContentReport _report(Map json) { + final id = json['id']; + final reason = json['reason']; + if (id is! String || reason is! String) { + throw ServerException(message: 'Unexpected report in response'); + } + return ContentReport( + id: id, + reason: reason, + details: json['details'] as String?, + createdAt: _date(json['created_at']), + reporterName: json['reporter_name'] as String?, + listingId: json['listing_id'] as String?, + listingTitle: json['listing_title'] as String?, + listingStatus: json['listing_status'] as String?, + reportedUserId: json['reported_user_id'] as String?, + reportedUserName: json['reported_user_name'] as String?, + ); + } + static DateTime? _date(Object? value) => value is String ? DateTime.tryParse(value)?.toLocal() : null; } diff --git a/lib/features/admin/data/repositories/admin_repository_impl.dart b/lib/features/admin/data/repositories/admin_repository_impl.dart index 9ccba71..a737c00 100644 --- a/lib/features/admin/data/repositories/admin_repository_impl.dart +++ b/lib/features/admin/data/repositories/admin_repository_impl.dart @@ -59,6 +59,20 @@ class AdminRepositoryImpl implements AdminRepository { Future> rejectId(String userId, String note) => _done(() => _dataSource.rejectId(userId, note)); + @override + Future>> reports() => + _guard(() => _dataSource.reports()); + + @override + Future> dismissReport(String reportId, String note) => + _done(() => _dataSource.dismissReport(reportId, note)); + + @override + Future> removeReportedListing( + String reportId, + String note, + ) => _done(() => _dataSource.removeReportedListing(reportId, note)); + @override Future> idPhotoUrl(String path) => _guard(() => _dataSource.idPhotoUrl(path)); diff --git a/lib/features/admin/domain/entities/admin_cases.dart b/lib/features/admin/domain/entities/admin_cases.dart index f9ff10e..7f97df7 100644 --- a/lib/features/admin/domain/entities/admin_cases.dart +++ b/lib/features/admin/domain/entities/admin_cases.dart @@ -73,3 +73,36 @@ class UnmatchedPayment { this.payerPhoneHint, }); } + +/// An open user report about a listing or a user. +class ContentReport { + final String id; + + /// One of spam, scam, inappropriate, harassment, prohibited, other. + final String reason; + final String? details; + final DateTime? createdAt; + final String? reporterName; + final String? listingId; + final String? listingTitle; + final String? listingStatus; + + /// The reported user, or the seller of the reported listing. + final String? reportedUserId; + final String? reportedUserName; + + const ContentReport({ + required this.id, + required this.reason, + this.details, + this.createdAt, + this.reporterName, + this.listingId, + this.listingTitle, + this.listingStatus, + this.reportedUserId, + this.reportedUserName, + }); + + bool get isAboutListing => listingId != null; +} diff --git a/lib/features/admin/domain/repositories/admin_repository.dart b/lib/features/admin/domain/repositories/admin_repository.dart index 070a541..c4f3fb2 100644 --- a/lib/features/admin/domain/repositories/admin_repository.dart +++ b/lib/features/admin/domain/repositories/admin_repository.dart @@ -39,6 +39,18 @@ abstract class AdminRepository { /// Marks the user rejected, shows them [note] and deletes their ID photos. Future> rejectId(String userId, String note); + Future>> reports(); + + /// Closes a report without acting on it. + Future> dismissReport(String reportId, String note); + + /// Takes the reported listing off the market and closes every open report + /// about it. + Future> removeReportedListing( + String reportId, + String note, + ); + /// A short-lived URL for one ID photo. Future> idPhotoUrl(String path); } diff --git a/lib/features/admin/presentation/screens/admin_screen.dart b/lib/features/admin/presentation/screens/admin_screen.dart index ca9d2af..08ffb64 100644 --- a/lib/features/admin/presentation/screens/admin_screen.dart +++ b/lib/features/admin/presentation/screens/admin_screen.dart @@ -1,11 +1,12 @@ import 'package:book_bridge/features/admin/domain/entities/admin_cases.dart'; import 'package:book_bridge/features/admin/presentation/viewmodels/admin_viewmodel.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; -/// Internal tool for resolving disputes and unmatched payments and reviewing -/// ID submissions. Opened by +/// Internal tool for resolving disputes and unmatched payments, reviewing +/// ID submissions and acting on user reports. Opened by /// long-pressing "About BookBridge" on the profile screen; the server /// decides who may use it. English-only, like the rest of the admin tooling. class AdminScreen extends StatelessWidget { @@ -15,7 +16,7 @@ class AdminScreen extends StatelessWidget { Widget build(BuildContext context) { final vm = context.watch(); return DefaultTabController( - length: 3, + length: 4, child: Scaffold( appBar: AppBar( title: const Text('Admin'), @@ -28,7 +29,9 @@ class AdminScreen extends StatelessWidget { Tab(text: 'Disputes (${vm.disputes.length})'), Tab(text: 'Unmatched (${vm.unmatched.length})'), Tab(text: 'IDs (${vm.idSubmissions.length})'), + Tab(text: 'Reports (${vm.reports.length})'), ], + isScrollable: true, ), ), body: switch (vm.state) { @@ -80,6 +83,13 @@ class AdminScreen extends StatelessWidget { _IdSubmissionCard(submission: s), ], ), + _CaseList( + empty: 'No open reports', + onRefresh: vm.refresh, + children: [ + for (final r in vm.reports) _ReportCard(report: r), + ], + ), ], ), ), @@ -399,6 +409,101 @@ class _IdSubmissionCard extends StatelessWidget { } } +String _reasonLabel(String reason) => switch (reason) { + 'spam' => 'Spam', + 'scam' => 'Scam or fraud', + 'inappropriate' => 'Inappropriate content', + 'harassment' => 'Harassment', + 'prohibited' => 'Prohibited item', + _ => 'Other', +}; + +class _ReportCard extends StatelessWidget { + final ContentReport report; + + const _ReportCard({required this.report}); + + @override + Widget build(BuildContext context) { + final vm = context.watch(); + final busy = vm.busyId != null; + final r = report; + final theme = Theme.of(context); + return Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(_reasonLabel(r.reason), style: theme.textTheme.titleMedium), + const SizedBox(height: 4), + if (r.isAboutListing) + Text( + 'Listing: ${r.listingTitle ?? 'deleted'}' + '${r.listingStatus == null ? '' : ' (${r.listingStatus})'}', + ), + Text( + '${r.isAboutListing ? 'Seller' : 'User'}: ' + '${r.reportedUserName ?? 'unknown'}', + ), + Text('Reported by: ${r.reporterName ?? 'deleted account'}'), + Text('Received: ${_when(r.createdAt)}'), + if (r.details != null && r.details!.isNotEmpty) ...[ + const SizedBox(height: 8), + Text('"${r.details}"', style: theme.textTheme.bodyMedium), + ], + if (r.reportedUserId != null) + SelectableText( + 'User ${r.reportedUserId}', + style: theme.textTheme.bodySmall, + ), + const SizedBox(height: 8), + if (vm.busyId == r.id) const LinearProgressIndicator(), + OverflowBar( + alignment: MainAxisAlignment.end, + spacing: 8, + children: [ + if (r.listingId != null) + TextButton( + onPressed: () => context.push('/listing/${r.listingId}'), + child: const Text('View listing'), + ), + OutlinedButton( + onPressed: busy + ? null + : () => _resolve( + context, + title: 'Dismiss report', + consequence: + 'Closes this report without changing anything.', + run: (note, _) => vm.dismissReport(r.id, note), + ), + child: const Text('Dismiss'), + ), + if (r.isAboutListing && r.listingStatus != 'removed') + FilledButton( + onPressed: busy + ? null + : () => _resolve( + context, + title: 'Remove listing', + consequence: + 'Takes the listing off the market and closes ' + 'every open report about it.', + run: (note, _) => + vm.removeReportedListing(r.id, note), + ), + child: const Text('Remove listing'), + ), + ], + ), + ], + ), + ), + ); + } +} + /// Loads a short-lived signed URL for one ID photo; tap to zoom. class _IdPhoto extends StatefulWidget { final String path; diff --git a/lib/features/admin/presentation/viewmodels/admin_viewmodel.dart b/lib/features/admin/presentation/viewmodels/admin_viewmodel.dart index e3f0e21..089d3e7 100644 --- a/lib/features/admin/presentation/viewmodels/admin_viewmodel.dart +++ b/lib/features/admin/presentation/viewmodels/admin_viewmodel.dart @@ -15,6 +15,7 @@ class AdminViewModel extends ChangeNotifier { List _disputes = const []; List _unmatched = const []; List _idSubmissions = const []; + List _reports = const []; String? _error; String? _busyId; @@ -22,6 +23,7 @@ class AdminViewModel extends ChangeNotifier { List get disputes => _disputes; List get unmatched => _unmatched; List get idSubmissions => _idSubmissions; + List get reports => _reports; /// Why the screen is denied, or why the last load failed. String? get error => _error; @@ -52,14 +54,21 @@ class AdminViewModel extends ChangeNotifier { _repository.disputes(), _repository.unmatchedPayments(), _repository.idVerifications(), + _repository.reports(), ]); final disputes = results[0] as Either>; final unmatched = results[1] as Either>; final ids = results[2] as Either>; + final reports = results[3] as Either>; _disputes = disputes.getOrElse(() => _disputes); _unmatched = unmatched.getOrElse(() => _unmatched); _idSubmissions = ids.getOrElse(() => _idSubmissions); - _error = _message(disputes) ?? _message(unmatched) ?? _message(ids); + _reports = reports.getOrElse(() => _reports); + _error = + _message(disputes) ?? + _message(unmatched) ?? + _message(ids) ?? + _message(reports); _state = AdminLoadState.loaded; notifyListeners(); } @@ -92,6 +101,12 @@ class AdminViewModel extends ChangeNotifier { Future rejectId(String userId, String note) => _act(userId, () => _repository.rejectId(userId, note)); + Future dismissReport(String reportId, String note) => + _act(reportId, () => _repository.dismissReport(reportId, note)); + + Future removeReportedListing(String reportId, String note) => + _act(reportId, () => _repository.removeReportedListing(reportId, note)); + /// Resolves to a short-lived photo URL; throws the failure message. Future idPhotoUrl(String path) async { final result = await _repository.idPhotoUrl(path); diff --git a/lib/features/chat/data/datasources/supabase_chat_data_source.dart b/lib/features/chat/data/datasources/supabase_chat_data_source.dart index 9b48a49..a0b8d05 100644 --- a/lib/features/chat/data/datasources/supabase_chat_data_source.dart +++ b/lib/features/chat/data/datasources/supabase_chat_data_source.dart @@ -1,6 +1,7 @@ import 'package:book_bridge/core/error/exceptions.dart'; import 'package:book_bridge/features/chat/data/models/message_model.dart'; import 'package:book_bridge/features/chat/domain/entities/conversation.dart'; +import 'package:book_bridge/features/moderation/domain/blocked_users_cache.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; /// Data source for chat operations using Supabase. @@ -10,7 +11,10 @@ import 'package:supabase_flutter/supabase_flutter.dart'; class SupabaseChatDataSource { final SupabaseClient supabaseClient; - SupabaseChatDataSource({required this.supabaseClient}); + /// Conversations with these users are hidden. + final BlockedUsersCache? blockedUsers; + + SupabaseChatDataSource({required this.supabaseClient, this.blockedUsers}); String get _currentUserId => supabaseClient.auth.currentUser!.id; @@ -53,6 +57,12 @@ class SupabaseChatDataSource { 'content': content, 'is_read': false, }); + } on PostgrestException catch (e) { + // Raised by the reject_blocked_message trigger. + if (e.message.startsWith('blocked')) { + throw MessagingBlockedException(message: e.message); + } + throw ServerException(message: 'Failed to send message: ${e.message}'); } catch (e) { throw ServerException(message: 'Failed to send message: $e'); } @@ -104,6 +114,7 @@ class SupabaseChatDataSource { final otherUserId = row['sender_id'] == _currentUserId ? row['receiver_id'] as String : row['sender_id'] as String; + if (blockedUsers?.contains(otherUserId) ?? false) continue; final key = '${row['listing_id']}_$otherUserId'; if (!convMap.containsKey(key)) { convMap[key] = { diff --git a/lib/features/chat/presentation/screens/chat_screen.dart b/lib/features/chat/presentation/screens/chat_screen.dart index c6924a4..6b76e41 100644 --- a/lib/features/chat/presentation/screens/chat_screen.dart +++ b/lib/features/chat/presentation/screens/chat_screen.dart @@ -14,6 +14,10 @@ import 'package:book_bridge/l10n/app_localizations.dart'; import 'package:go_router/go_router.dart'; import 'package:book_bridge/core/theme/app_theme.dart'; import 'package:book_bridge/injection_container.dart'; +import 'package:book_bridge/features/moderation/domain/entities/blocked_user.dart'; +import 'package:book_bridge/features/moderation/presentation/viewmodels/moderation_viewmodel.dart'; +import 'package:book_bridge/features/moderation/presentation/widgets/moderation_actions.dart'; +import 'package:book_bridge/features/moderation/presentation/widgets/moderation_menu.dart'; /// Full-screen real-time chat thread for a specific listing conversation. class ChatScreen extends StatefulWidget { @@ -157,6 +161,7 @@ class _ChatScreenState extends State { ), ), ), + ModerationMenu(user: _otherUser, iconColor: Colors.white), ], ), body: Column( @@ -237,7 +242,9 @@ class _ChatScreenState extends State { vertical: 8, ), child: Text( - vm.sendError!, + vm.sendBlocked + ? AppLocalizations.of(context)!.chatSendBlocked + : vm.sendError!, style: TextStyle(color: theme.colorScheme.onErrorContainer), ), ); @@ -245,12 +252,47 @@ class _ChatScreenState extends State { ), // ─── Input Bar ───────────────────────────────────────────────── - _buildInputBar(theme), + context.select( + (vm) => vm.isBlocked(widget.otherUserId), + ) + ? _buildBlockedBar(theme) + : _buildInputBar(theme), ], ), ); } + BlockedUser get _otherUser => + BlockedUser(id: widget.otherUserId, name: widget.otherUserName); + + Widget _buildBlockedBar(ThemeData theme) { + return SafeArea( + child: Container( + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + border: Border( + top: BorderSide(color: theme.colorScheme.outlineVariant), + ), + ), + padding: const EdgeInsets.fromLTRB(16, 8, 8, 8), + child: Row( + children: [ + Expanded( + child: Text( + AppLocalizations.of(context)!.chatBlockedBanner, + style: theme.textTheme.bodyMedium, + ), + ), + TextButton( + onPressed: () => ModerationActions.unblock(context, _otherUser), + child: Text(AppLocalizations.of(context)!.unblockUser), + ), + ], + ), + ), + ); + } + Widget _buildInputBar(ThemeData theme) { return SafeArea( child: Container( diff --git a/lib/features/chat/presentation/viewmodels/chat_viewmodel.dart b/lib/features/chat/presentation/viewmodels/chat_viewmodel.dart index 79f5d22..d63ee68 100644 --- a/lib/features/chat/presentation/viewmodels/chat_viewmodel.dart +++ b/lib/features/chat/presentation/viewmodels/chat_viewmodel.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:book_bridge/core/error/exceptions.dart'; import 'package:book_bridge/features/chat/domain/entities/conversation.dart'; import 'package:book_bridge/features/chat/domain/entities/message.dart'; import 'package:book_bridge/features/chat/domain/repositories/chat_repository.dart'; @@ -44,6 +45,10 @@ class ChatViewModel extends ChangeNotifier { String? _sendError; String? get sendError => _sendError; + /// True when the last send failed because one user blocked the other. + bool _sendBlocked = false; + bool get sendBlocked => _sendBlocked; + // ─── Conversations ──────────────────────────────────────────────────────── Future loadConversations() async { @@ -112,6 +117,7 @@ class ChatViewModel extends ChangeNotifier { if (content.trim().isEmpty) return; _isSending = true; _sendError = null; + _sendBlocked = false; notifyListeners(); try { await _repository.sendMessage( @@ -119,6 +125,9 @@ class ChatViewModel extends ChangeNotifier { receiverId: receiverId, content: content.trim(), ); + } on MessagingBlockedException { + _sendBlocked = true; + _sendError = 'You can\'t message this user.'; } catch (e) { _sendError = 'Failed to send message. Please try again.'; } diff --git a/lib/features/listings/data/datasources/supabase_listings_data_source.dart b/lib/features/listings/data/datasources/supabase_listings_data_source.dart index 2001ece..6dc7c64 100644 --- a/lib/features/listings/data/datasources/supabase_listings_data_source.dart +++ b/lib/features/listings/data/datasources/supabase_listings_data_source.dart @@ -5,6 +5,7 @@ import 'package:book_bridge/features/listings/data/models/listing_model.dart'; import 'package:book_bridge/features/listings/data/datasources/supabase_storage_data_source.dart'; import 'package:book_bridge/features/listings/domain/entities/academic_lookups.dart'; import 'package:book_bridge/features/listings/domain/entities/book_condition.dart'; +import 'package:book_bridge/features/moderation/domain/blocked_users_cache.dart'; import 'package:book_bridge/features/subscriptions/domain/subscription_constants.dart'; /// Data source for listing operations using Supabase PostgreSQL and Storage. @@ -22,11 +23,17 @@ class SupabaseListingsDataSource { final SupabaseClient supabaseClient; final SupabaseStorageDataSource storageDataSource; + /// Sellers whose listings are hidden from the feed and search. + final BlockedUsersCache? blockedUsers; + SupabaseListingsDataSource({ required this.supabaseClient, required this.storageDataSource, + this.blockedUsers, }); + Set get _blockedIds => blockedUsers?.ids ?? const {}; + /// Fetches all academic categories. Future>> getCategories() async { // For now, we return a hardcoded list of real Cameroonian categories. @@ -99,6 +106,10 @@ class SupabaseListingsDataSource { if (filters.schoolId != null) { query = query.eq('school_id', filters.schoolId!); } + final blocked = _blockedIds; + if (blocked.isNotEmpty) { + query = query.not('seller_id', 'in', blocked.toList()); + } final response = await query .order('is_boosted', ascending: false) @@ -176,11 +187,14 @@ class SupabaseListingsDataSource { // search_listings applies _limit internally, before PostgREST filters // run. Widen the RPC window when filtering so matches aren't cut off, // then apply the caller's limit after filtering. + final blocked = _blockedIds; var request = supabaseClient.rpc( 'search_listings', params: { 'query': query, - '_limit': filters.isEmpty ? limit : _filteredSearchWindow, + '_limit': filters.isEmpty && blocked.isEmpty + ? limit + : _filteredSearchWindow, '_offset': 0, }, ); @@ -193,6 +207,9 @@ class SupabaseListingsDataSource { if (filters.schoolId != null) { request = request.eq('school_id', filters.schoolId!); } + if (blocked.isNotEmpty) { + request = request.not('seller_id', 'in', blocked.toList()); + } final response = await request .select(_listingSelect) diff --git a/lib/features/listings/data/repositories/listing_repository_impl.dart b/lib/features/listings/data/repositories/listing_repository_impl.dart index fed3900..71e9948 100644 --- a/lib/features/listings/data/repositories/listing_repository_impl.dart +++ b/lib/features/listings/data/repositories/listing_repository_impl.dart @@ -118,9 +118,13 @@ class ListingRepositoryImpl implements ListingRepository { final cached = await localDataSource.getCachedListings( category: category, ); - if (cached.isNotEmpty) { + final blocked = dataSource.blockedUsers; + final visible = blocked == null + ? cached + : cached.where((m) => !blocked.contains(m.sellerId)).toList(); + if (visible.isNotEmpty) { _isServingFromCache = true; - return Right(cached.map((m) => m.toEntity()).toList()); + return Right(visible.map((m) => m.toEntity()).toList()); } } } catch (_) { diff --git a/lib/features/listings/presentation/screens/listing_details_screen.dart b/lib/features/listings/presentation/screens/listing_details_screen.dart index 27d7bde..db5444e 100644 --- a/lib/features/listings/presentation/screens/listing_details_screen.dart +++ b/lib/features/listings/presentation/screens/listing_details_screen.dart @@ -19,6 +19,8 @@ import 'package:book_bridge/core/theme/app_theme.dart'; import 'package:book_bridge/core/utils/listing_share.dart'; import 'package:book_bridge/features/safety/presentation/widgets/meetup_tips_card.dart'; import 'package:book_bridge/features/listings/presentation/widgets/meetup_info_card.dart'; +import 'package:book_bridge/features/moderation/domain/entities/blocked_user.dart'; +import 'package:book_bridge/features/moderation/presentation/widgets/moderation_menu.dart'; /// Listing details screen showing comprehensive information about a book. /// @@ -142,6 +144,7 @@ class _ListingDetailsScreenState extends State { ); }, ), + _buildModerationMenu(viewModel.listing), ], ), ), @@ -155,6 +158,37 @@ class _ListingDetailsScreenState extends State { ); } + /// Report and block options, shown to signed-in buyers only. + Widget _buildModerationMenu(Listing? listing) { + final userId = context.select( + (vm) => vm.currentUser?.id, + ); + if (listing == null || + userId == null || + listing.sellerId.isEmpty || + listing.sellerId == userId) { + return const SizedBox.shrink(); + } + return ModerationMenu( + listingId: listing.id, + isSeller: true, + iconColor: Colors.white, + user: BlockedUser( + id: listing.sellerId, + name: listing.sellerName, + avatarUrl: listing.sellerAvatarUrl, + ), + onBlocked: () { + if (!mounted) return; + if (Navigator.of(context).canPop()) { + Navigator.of(context).pop(); + } else { + context.go('/home'); + } + }, + ); + } + Widget _buildBody(ListingDetailsViewModel viewModel) { if (viewModel.isLoading) { return const Center(child: CircularProgressIndicator()); diff --git a/lib/features/listings/presentation/screens/profile_screen.dart b/lib/features/listings/presentation/screens/profile_screen.dart index 1cb29da..2deaabc 100644 --- a/lib/features/listings/presentation/screens/profile_screen.dart +++ b/lib/features/listings/presentation/screens/profile_screen.dart @@ -367,6 +367,12 @@ class _ProfileScreenState extends State { ); }, ), + _buildMenuItem( + context, + icon: Icons.block, + title: AppLocalizations.of(context)!.blockedUsersTitle, + onTap: () => context.push('/blocked-users'), + ), _buildMenuItem( context, icon: Icons.info_outline_rounded, diff --git a/lib/features/moderation/data/datasources/supabase_moderation_data_source.dart b/lib/features/moderation/data/datasources/supabase_moderation_data_source.dart new file mode 100644 index 0000000..99cfd21 --- /dev/null +++ b/lib/features/moderation/data/datasources/supabase_moderation_data_source.dart @@ -0,0 +1,109 @@ +import 'package:book_bridge/core/error/exceptions.dart'; +import 'package:book_bridge/features/moderation/domain/entities/blocked_user.dart'; +import 'package:book_bridge/features/moderation/domain/entities/report_reason.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +/// Thrown when the server refuses a report because the daily limit was hit. +class ReportLimitException extends ServerException { + ReportLimitException({required super.message}); +} + +/// `user_blocks` and `content_reports` through PostgREST; RLS limits both +/// to the signed-in user's own rows. +class SupabaseModerationDataSource { + static const int maxDetailsLength = 500; + static const String _uniqueViolation = '23505'; + + final SupabaseClient supabaseClient; + + SupabaseModerationDataSource({required this.supabaseClient}); + + String get _userId { + final user = supabaseClient.auth.currentUser; + if (user == null) throw AuthAppException(message: 'Not signed in'); + return user.id; + } + + Future> getBlockedUsers() async { + try { + final rows = await supabaseClient + .from('user_blocks') + .select('blocked_id') + .eq('blocker_id', _userId) + .order('created_at', ascending: false); + final ids = [for (final row in rows) row['blocked_id'] as String]; + if (ids.isEmpty) return const []; + + final profiles = await supabaseClient + .from('public_profiles') + .select('id, full_name, avatar_url') + .inFilter('id', ids); + final byId = {for (final p in profiles) p['id'] as String: p}; + return [ + for (final id in ids) + BlockedUser( + id: id, + name: byId[id]?['full_name'] as String?, + avatarUrl: byId[id]?['avatar_url'] as String?, + ), + ]; + } on PostgrestException catch (e) { + throw ServerException(message: e.message); + } + } + + Future blockUser(String userId) async { + try { + await supabaseClient.from('user_blocks').insert({ + 'blocker_id': _userId, + 'blocked_id': userId, + }); + } on PostgrestException catch (e) { + if (e.code == _uniqueViolation) return; + throw ServerException(message: e.message); + } + } + + Future unblockUser(String userId) async { + try { + await supabaseClient + .from('user_blocks') + .delete() + .eq('blocker_id', _userId) + .eq('blocked_id', userId); + } on PostgrestException catch (e) { + throw ServerException(message: e.message); + } + } + + /// Files a report about a listing or a user. The server sets the reporter + /// from the session; the client cannot read reports back. + Future report({ + String? listingId, + String? reportedUserId, + required ReportReason reason, + String? details, + }) async { + assert(listingId != null || reportedUserId != null); + _userId; // Fails fast when signed out. + final text = details?.trim() ?? ''; + try { + await supabaseClient.from('content_reports').insert({ + 'listing_id': ?listingId, + 'reported_user_id': ?reportedUserId, + 'reason': reason.wire, + if (text.isNotEmpty) + 'details': text.length > maxDetailsLength + ? text.substring(0, maxDetailsLength) + : text, + }); + } on PostgrestException catch (e) { + // Already reported and still open: nothing more to do. + if (e.code == _uniqueViolation) return; + if (e.message.contains('report_limit')) { + throw ReportLimitException(message: e.message); + } + throw ServerException(message: e.message); + } + } +} diff --git a/lib/features/moderation/data/repositories/moderation_repository_impl.dart b/lib/features/moderation/data/repositories/moderation_repository_impl.dart new file mode 100644 index 0000000..1aba992 --- /dev/null +++ b/lib/features/moderation/data/repositories/moderation_repository_impl.dart @@ -0,0 +1,75 @@ +import 'package:book_bridge/core/error/exceptions.dart'; +import 'package:book_bridge/core/error/failures.dart'; +import 'package:book_bridge/features/moderation/data/datasources/supabase_moderation_data_source.dart'; +import 'package:book_bridge/features/moderation/domain/entities/blocked_user.dart'; +import 'package:book_bridge/features/moderation/domain/entities/moderation_failure.dart'; +import 'package:book_bridge/features/moderation/domain/entities/report_reason.dart'; +import 'package:book_bridge/features/moderation/domain/repositories/moderation_repository.dart'; +import 'package:dartz/dartz.dart'; + +class ModerationRepositoryImpl implements ModerationRepository { + final SupabaseModerationDataSource dataSource; + + ModerationRepositoryImpl({required this.dataSource}); + + @override + Future>> getBlockedUsers() => + _guard(dataSource.getBlockedUsers); + + @override + Future> blockUser(String userId) => + _done(() => dataSource.blockUser(userId)); + + @override + Future> unblockUser(String userId) => + _done(() => dataSource.unblockUser(userId)); + + @override + Future> reportListing( + String listingId, + ReportReason reason, { + String? details, + }) => _done( + () => dataSource.report( + listingId: listingId, + reason: reason, + details: details, + ), + ); + + @override + Future> reportUser( + String userId, + ReportReason reason, { + String? details, + }) => _done( + () => dataSource.report( + reportedUserId: userId, + reason: reason, + details: details, + ), + ); + + Future> _done(Future Function() run) => + _guard(() async { + await run(); + return unit; + }); + + Future> _guard(Future Function() run) async { + try { + return Right(await run()); + } on ReportLimitException catch (e) { + return Left( + ModerationFailure( + message: e.message, + kind: ModerationFailureKind.reportLimit, + ), + ); + } on AppException catch (e) { + return Left(ModerationFailure(message: e.message)); + } catch (e) { + return Left(ModerationFailure(message: e.toString())); + } + } +} diff --git a/lib/features/moderation/domain/blocked_users_cache.dart b/lib/features/moderation/domain/blocked_users_cache.dart new file mode 100644 index 0000000..1482888 --- /dev/null +++ b/lib/features/moderation/domain/blocked_users_cache.dart @@ -0,0 +1,21 @@ +/// The ids of users the signed-in user has blocked, shared with the data +/// sources that hide their listings and conversations. Kept up to date by +/// `ModerationViewModel`. +class BlockedUsersCache { + Set _ids = const {}; + + Set get ids => _ids; + + bool get isEmpty => _ids.isEmpty; + + bool contains(String userId) => _ids.contains(userId); + + void replaceAll(Iterable ids) => _ids = Set.unmodifiable(ids); + + void add(String userId) => _ids = Set.unmodifiable({..._ids, userId}); + + void remove(String userId) => + _ids = Set.unmodifiable(_ids.where((id) => id != userId)); + + void clear() => _ids = const {}; +} diff --git a/lib/features/moderation/domain/entities/blocked_user.dart b/lib/features/moderation/domain/entities/blocked_user.dart new file mode 100644 index 0000000..c14a4bc --- /dev/null +++ b/lib/features/moderation/domain/entities/blocked_user.dart @@ -0,0 +1,13 @@ +import 'package:equatable/equatable.dart'; + +/// Someone the signed-in user has blocked. +class BlockedUser extends Equatable { + final String id; + final String? name; + final String? avatarUrl; + + const BlockedUser({required this.id, this.name, this.avatarUrl}); + + @override + List get props => [id, name, avatarUrl]; +} diff --git a/lib/features/moderation/domain/entities/moderation_failure.dart b/lib/features/moderation/domain/entities/moderation_failure.dart new file mode 100644 index 0000000..2e20e31 --- /dev/null +++ b/lib/features/moderation/domain/entities/moderation_failure.dart @@ -0,0 +1,22 @@ +import 'package:book_bridge/core/error/failures.dart'; + +enum ModerationFailureKind { + /// The daily report limit was reached. + reportLimit, + + /// One of the two users has blocked the other. + blocked, + other, +} + +class ModerationFailure extends Failure { + final ModerationFailureKind kind; + + const ModerationFailure({ + required super.message, + this.kind = ModerationFailureKind.other, + }); + + @override + List get props => [message, kind]; +} diff --git a/lib/features/moderation/domain/entities/report_reason.dart b/lib/features/moderation/domain/entities/report_reason.dart new file mode 100644 index 0000000..301a6ca --- /dev/null +++ b/lib/features/moderation/domain/entities/report_reason.dart @@ -0,0 +1,14 @@ +/// Why a user reports a listing or another user. [wire] matches the +/// `content_reports.reason` check constraint. +enum ReportReason { + spam('spam'), + scam('scam'), + inappropriate('inappropriate'), + harassment('harassment'), + prohibited('prohibited'), + other('other'); + + const ReportReason(this.wire); + + final String wire; +} diff --git a/lib/features/moderation/domain/repositories/moderation_repository.dart b/lib/features/moderation/domain/repositories/moderation_repository.dart new file mode 100644 index 0000000..5a13b6e --- /dev/null +++ b/lib/features/moderation/domain/repositories/moderation_repository.dart @@ -0,0 +1,30 @@ +import 'package:book_bridge/core/error/failures.dart'; +import 'package:book_bridge/features/moderation/domain/entities/blocked_user.dart'; +import 'package:book_bridge/features/moderation/domain/entities/report_reason.dart'; +import 'package:dartz/dartz.dart'; + +/// Reporting and blocking, required for user-generated content on Google +/// Play. Reports go to the admin queue; blocks hide the other user's +/// listings and conversations and stop messages in both directions. +abstract class ModerationRepository { + Future>> getBlockedUsers(); + + /// Blocking someone already blocked succeeds. + Future> blockUser(String userId); + + Future> unblockUser(String userId); + + /// Reporting the same listing again while the first report is open + /// succeeds without filing a duplicate. + Future> reportListing( + String listingId, + ReportReason reason, { + String? details, + }); + + Future> reportUser( + String userId, + ReportReason reason, { + String? details, + }); +} diff --git a/lib/features/moderation/presentation/screens/blocked_users_screen.dart b/lib/features/moderation/presentation/screens/blocked_users_screen.dart new file mode 100644 index 0000000..6936487 --- /dev/null +++ b/lib/features/moderation/presentation/screens/blocked_users_screen.dart @@ -0,0 +1,97 @@ +import 'package:book_bridge/features/auth/presentation/viewmodels/auth_viewmodel.dart'; +import 'package:book_bridge/features/moderation/presentation/viewmodels/moderation_viewmodel.dart'; +import 'package:book_bridge/features/moderation/presentation/widgets/moderation_actions.dart'; +import 'package:book_bridge/l10n/app_localizations.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +/// Lists the people the user has blocked, with an Unblock button each. +class BlockedUsersScreen extends StatefulWidget { + const BlockedUsersScreen({super.key}); + + @override + State createState() => _BlockedUsersScreenState(); +} + +class _BlockedUsersScreenState extends State { + final Set _busy = {}; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _load()); + } + + Future _load() { + final userId = context.read().currentUser?.id; + return context.read().load(userId); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final vm = context.watch(); + final users = vm.blockedUsers; + + Widget body; + if (vm.isLoading && users.isEmpty) { + body = const Center(child: CircularProgressIndicator()); + } else if (vm.loadFailure != null && users.isEmpty) { + body = Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(l10n.somethingWentWrong), + const SizedBox(height: 8), + TextButton(onPressed: _load, child: Text(l10n.retry)), + ], + ), + ); + } else if (users.isEmpty) { + body = Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text(l10n.blockedUsersEmpty, textAlign: TextAlign.center), + ), + ); + } else { + body = RefreshIndicator( + onRefresh: _load, + child: ListView.separated( + itemCount: users.length, + separatorBuilder: (_, _) => const Divider(height: 1), + itemBuilder: (context, index) { + final user = users[index]; + final avatar = user.avatarUrl; + return ListTile( + leading: CircleAvatar( + backgroundImage: avatar != null && avatar.isNotEmpty + ? NetworkImage(avatar) + : null, + child: avatar == null || avatar.isEmpty + ? const Icon(Icons.person) + : null, + ), + title: Text(ModerationActions.displayName(l10n, user.name)), + trailing: OutlinedButton( + onPressed: _busy.contains(user.id) + ? null + : () async { + setState(() => _busy.add(user.id)); + await ModerationActions.unblock(context, user); + if (mounted) setState(() => _busy.remove(user.id)); + }, + child: Text(l10n.unblockUser), + ), + ); + }, + ), + ); + } + + return Scaffold( + appBar: AppBar(title: Text(l10n.blockedUsersTitle)), + body: body, + ); + } +} diff --git a/lib/features/moderation/presentation/viewmodels/moderation_viewmodel.dart b/lib/features/moderation/presentation/viewmodels/moderation_viewmodel.dart new file mode 100644 index 0000000..bb26c35 --- /dev/null +++ b/lib/features/moderation/presentation/viewmodels/moderation_viewmodel.dart @@ -0,0 +1,115 @@ +import 'package:book_bridge/core/error/failures.dart'; +import 'package:book_bridge/features/moderation/domain/blocked_users_cache.dart'; +import 'package:book_bridge/features/moderation/domain/entities/blocked_user.dart'; +import 'package:book_bridge/features/moderation/domain/entities/report_reason.dart'; +import 'package:book_bridge/features/moderation/domain/repositories/moderation_repository.dart'; +import 'package:flutter/foundation.dart'; + +/// The signed-in user's blocks, plus report and block actions. Keeps +/// [BlockedUsersCache] in step so listings and chats hide blocked users. +class ModerationViewModel extends ChangeNotifier { + final ModerationRepository _repository; + final BlockedUsersCache _cache; + + ModerationViewModel({ + required ModerationRepository repository, + required BlockedUsersCache cache, + }) : _repository = repository, + _cache = cache; + + List _blockedUsers = const []; + String? _userId; + bool _isLoading = false; + Failure? _loadFailure; + + List get blockedUsers => _blockedUsers; + bool get isLoading => _isLoading; + Failure? get loadFailure => _loadFailure; + + bool isBlocked(String userId) => _cache.contains(userId); + + /// Loads [userId]'s blocks, or clears them when signed out. Returns true + /// when the set of blocked users changed, so callers can refresh lists. + Future load(String? userId) async { + if (userId == null || userId.isEmpty) { + final hadBlocks = !_cache.isEmpty; + _userId = null; + _setBlocked(const []); + return hadBlocks; + } + if (userId != _userId) { + // Never apply another account's blocks, even briefly. + _userId = userId; + _setBlocked(const []); + } + final before = _cache.ids; + _isLoading = true; + _loadFailure = null; + notifyListeners(); + final result = await _repository.getBlockedUsers(); + _isLoading = false; + if (_userId != userId) return false; + result.fold((failure) => _loadFailure = failure, _setBlocked); + notifyListeners(); + return !setEquals(before, _cache.ids); + } + + /// Forgets the current user's blocks without notifying listeners, for use + /// while the widget tree is being torn down on sign-out. + void reset() { + _userId = null; + _isLoading = false; + _loadFailure = null; + _setBlocked(const []); + } + + /// Returns null on success or the failure to show. + Future block(BlockedUser user) async { + final result = await _repository.blockUser(user.id); + return result.fold((failure) => failure, (_) { + if (!isBlocked(user.id)) _setBlocked([user, ..._blockedUsers]); + notifyListeners(); + return null; + }); + } + + Future unblock(String userId) async { + final result = await _repository.unblockUser(userId); + return result.fold((failure) => failure, (_) { + _setBlocked(_blockedUsers.where((u) => u.id != userId).toList()); + notifyListeners(); + return null; + }); + } + + Future reportListing( + String listingId, + ReportReason reason, { + String? details, + }) async { + final result = await _repository.reportListing( + listingId, + reason, + details: details, + ); + return result.fold((failure) => failure, (_) => null); + } + + Future reportUser( + String userId, + ReportReason reason, { + String? details, + }) async { + final result = await _repository.reportUser( + userId, + reason, + details: details, + ); + return result.fold((failure) => failure, (_) => null); + } + + void _setBlocked(List users) { + _blockedUsers = List.unmodifiable(users); + _cache.replaceAll(users.map((u) => u.id)); + } +} diff --git a/lib/features/moderation/presentation/widgets/moderation_actions.dart b/lib/features/moderation/presentation/widgets/moderation_actions.dart new file mode 100644 index 0000000..91d439d --- /dev/null +++ b/lib/features/moderation/presentation/widgets/moderation_actions.dart @@ -0,0 +1,111 @@ +import 'package:book_bridge/features/chat/presentation/viewmodels/chat_viewmodel.dart'; +import 'package:book_bridge/features/listings/presentation/viewmodels/home_viewmodel.dart'; +import 'package:book_bridge/features/moderation/domain/entities/blocked_user.dart'; +import 'package:book_bridge/features/moderation/presentation/viewmodels/moderation_viewmodel.dart'; +import 'package:book_bridge/features/moderation/presentation/widgets/report_sheet.dart'; +import 'package:book_bridge/l10n/app_localizations.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +/// Report and block flows shared by the listing, chat and blocked-users +/// screens. Each shows its own confirmation and result messages. +class ModerationActions { + const ModerationActions._(); + + static Future reportListing( + BuildContext context, + String listingId, + ) async { + final l10n = AppLocalizations.of(context)!; + final moderation = context.read(); + final sent = await showReportSheet( + context, + title: l10n.reportListing, + onSubmit: (reason, details) => + moderation.reportListing(listingId, reason, details: details), + ); + if (sent && context.mounted) _snack(context, l10n.reportSent); + } + + static Future reportUser( + BuildContext context, + String userId, { + required String title, + }) async { + final l10n = AppLocalizations.of(context)!; + final moderation = context.read(); + final sent = await showReportSheet( + context, + title: title, + onSubmit: (reason, details) => + moderation.reportUser(userId, reason, details: details), + ); + if (sent && context.mounted) _snack(context, l10n.reportSent); + } + + /// Asks for confirmation, then blocks [user]. Returns true once blocked. + static Future block(BuildContext context, BlockedUser user) async { + final l10n = AppLocalizations.of(context)!; + final name = displayName(l10n, user.name); + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(l10n.blockConfirmTitle(name)), + content: Text(l10n.blockConfirmBody), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: Text(l10n.cancel), + ), + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + style: TextButton.styleFrom( + foregroundColor: Theme.of(dialogContext).colorScheme.error, + ), + child: Text(l10n.blockConfirm), + ), + ], + ), + ); + if (confirmed != true || !context.mounted) return false; + + final failure = await context.read().block(user); + if (!context.mounted) return failure == null; + if (failure != null) { + _snack(context, l10n.moderationFailed); + return false; + } + _snack(context, l10n.userBlocked(name)); + _refreshFeeds(context); + return true; + } + + static Future unblock(BuildContext context, BlockedUser user) async { + final l10n = AppLocalizations.of(context)!; + final failure = await context.read().unblock(user.id); + if (!context.mounted) return failure == null; + if (failure != null) { + _snack(context, l10n.moderationFailed); + return false; + } + _snack(context, l10n.userUnblocked(displayName(l10n, user.name))); + _refreshFeeds(context); + return true; + } + + static String displayName(AppLocalizations l10n, String? name) { + final trimmed = name?.trim() ?? ''; + return trimmed.isEmpty ? l10n.blockedUserFallbackName : trimmed; + } + + static void _refreshFeeds(BuildContext context) { + context.read().refreshListings(); + context.read().refreshConversationsSilently(); + } + + static void _snack(BuildContext context, String message) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text(message))); + } +} diff --git a/lib/features/moderation/presentation/widgets/moderation_menu.dart b/lib/features/moderation/presentation/widgets/moderation_menu.dart new file mode 100644 index 0000000..85eb651 --- /dev/null +++ b/lib/features/moderation/presentation/widgets/moderation_menu.dart @@ -0,0 +1,85 @@ +import 'package:book_bridge/features/moderation/domain/entities/blocked_user.dart'; +import 'package:book_bridge/features/moderation/presentation/viewmodels/moderation_viewmodel.dart'; +import 'package:book_bridge/features/moderation/presentation/widgets/moderation_actions.dart'; +import 'package:book_bridge/l10n/app_localizations.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +enum _MenuAction { reportListing, reportUser, block, unblock } + +/// Overflow menu to report a listing or user, and block or unblock them. +class ModerationMenu extends StatelessWidget { + /// The listing being viewed, if any; adds "Report listing". + final String? listingId; + final BlockedUser user; + + /// True when [user] is the seller of [listingId]. + final bool isSeller; + final Color? iconColor; + + /// Called after [user] is blocked. + final VoidCallback? onBlocked; + + const ModerationMenu({ + super.key, + required this.user, + this.listingId, + this.isSeller = false, + this.iconColor, + this.onBlocked, + }); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final isBlocked = context.select( + (vm) => vm.isBlocked(user.id), + ); + final reportUserLabel = isSeller ? l10n.reportSeller : l10n.reportUser; + return PopupMenuButton<_MenuAction>( + tooltip: l10n.moreOptions, + icon: Icon(Icons.more_vert, color: iconColor), + onSelected: (action) async { + switch (action) { + case _MenuAction.reportListing: + await ModerationActions.reportListing(context, listingId!); + case _MenuAction.reportUser: + await ModerationActions.reportUser( + context, + user.id, + title: reportUserLabel, + ); + case _MenuAction.block: + final blocked = await ModerationActions.block(context, user); + if (blocked) onBlocked?.call(); + case _MenuAction.unblock: + await ModerationActions.unblock(context, user); + } + }, + itemBuilder: (_) => [ + if (listingId != null) + PopupMenuItem( + value: _MenuAction.reportListing, + child: _item(Icons.flag_outlined, l10n.reportListing), + ), + PopupMenuItem( + value: _MenuAction.reportUser, + child: _item(Icons.person_off_outlined, reportUserLabel), + ), + isBlocked + ? PopupMenuItem( + value: _MenuAction.unblock, + child: _item(Icons.lock_open_outlined, l10n.unblockUser), + ) + : PopupMenuItem( + value: _MenuAction.block, + child: _item(Icons.block, l10n.blockUser), + ), + ], + ); + } + + Widget _item(IconData icon, String label) => Row( + children: [Icon(icon, size: 20), const SizedBox(width: 12), Text(label)], + ); +} diff --git a/lib/features/moderation/presentation/widgets/report_sheet.dart b/lib/features/moderation/presentation/widgets/report_sheet.dart new file mode 100644 index 0000000..2bd9872 --- /dev/null +++ b/lib/features/moderation/presentation/widgets/report_sheet.dart @@ -0,0 +1,156 @@ +import 'package:book_bridge/core/error/failures.dart'; +import 'package:book_bridge/features/moderation/data/datasources/supabase_moderation_data_source.dart'; +import 'package:book_bridge/features/moderation/domain/entities/moderation_failure.dart'; +import 'package:book_bridge/features/moderation/domain/entities/report_reason.dart'; +import 'package:book_bridge/l10n/app_localizations.dart'; +import 'package:flutter/material.dart'; + +typedef SubmitReport = + Future Function(ReportReason reason, String? details); + +/// Asks for a reason and optional details, then calls [onSubmit]. Returns +/// true once the report was sent. +Future showReportSheet( + BuildContext context, { + required String title, + required SubmitReport onSubmit, +}) async { + final sent = await showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + builder: (_) => ReportSheet(title: title, onSubmit: onSubmit), + ); + return sent ?? false; +} + +String reportReasonLabel(AppLocalizations l10n, ReportReason reason) => + switch (reason) { + ReportReason.spam => l10n.reportReasonSpam, + ReportReason.scam => l10n.reportReasonScam, + ReportReason.inappropriate => l10n.reportReasonInappropriate, + ReportReason.harassment => l10n.reportReasonHarassment, + ReportReason.prohibited => l10n.reportReasonProhibited, + ReportReason.other => l10n.reportReasonOther, + }; + +class ReportSheet extends StatefulWidget { + final String title; + final SubmitReport onSubmit; + + const ReportSheet({super.key, required this.title, required this.onSubmit}); + + @override + State createState() => _ReportSheetState(); +} + +class _ReportSheetState extends State { + final _details = TextEditingController(); + ReportReason? _reason; + bool _sending = false; + String? _error; + + @override + void dispose() { + _details.dispose(); + super.dispose(); + } + + Future _submit() async { + final reason = _reason; + if (reason == null || _sending) return; + final l10n = AppLocalizations.of(context)!; + setState(() { + _sending = true; + _error = null; + }); + final failure = await widget.onSubmit(reason, _details.text); + if (!mounted) return; + if (failure == null) { + Navigator.of(context).pop(true); + return; + } + setState(() { + _sending = false; + _error = + failure is ModerationFailure && + failure.kind == ModerationFailureKind.reportLimit + ? l10n.reportLimitReached + : l10n.moderationFailed; + }); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final theme = Theme.of(context); + return Padding( + padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom), + child: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Text(widget.title, style: theme.textTheme.titleLarge), + const SizedBox(height: 4), + Text( + l10n.reportTitle, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + RadioGroup( + groupValue: _reason, + onChanged: (value) { + if (!_sending) setState(() => _reason = value); + }, + child: Column( + children: [ + for (final reason in ReportReason.values) + RadioListTile( + value: reason, + title: Text(reportReasonLabel(l10n, reason)), + contentPadding: EdgeInsets.zero, + dense: true, + ), + ], + ), + ), + const SizedBox(height: 8), + TextField( + controller: _details, + enabled: !_sending, + maxLength: SupabaseModerationDataSource.maxDetailsLength, + minLines: 2, + maxLines: 4, + textCapitalization: TextCapitalization.sentences, + decoration: InputDecoration( + hintText: l10n.reportDetailsHint, + border: const OutlineInputBorder(), + ), + ), + if (_error != null) ...[ + const SizedBox(height: 8), + Text(_error!, style: TextStyle(color: theme.colorScheme.error)), + ], + const SizedBox(height: 12), + FilledButton( + onPressed: _reason == null || _sending ? null : _submit, + child: _sending + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(l10n.reportSubmit), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/injection_container.dart b/lib/injection_container.dart index 76fdafd..121b69f 100644 --- a/lib/injection_container.dart +++ b/lib/injection_container.dart @@ -86,6 +86,11 @@ import 'package:book_bridge/features/auth/data/datasources/rust_account_data_sou import 'package:book_bridge/features/admin/data/repositories/admin_repository_impl.dart'; import 'package:book_bridge/features/admin/domain/repositories/admin_repository.dart'; import 'package:book_bridge/features/admin/presentation/viewmodels/admin_viewmodel.dart'; +import 'package:book_bridge/features/moderation/data/datasources/supabase_moderation_data_source.dart'; +import 'package:book_bridge/features/moderation/data/repositories/moderation_repository_impl.dart'; +import 'package:book_bridge/features/moderation/domain/blocked_users_cache.dart'; +import 'package:book_bridge/features/moderation/domain/repositories/moderation_repository.dart'; +import 'package:book_bridge/features/moderation/presentation/viewmodels/moderation_viewmodel.dart'; /// Service locator for dependency injection. /// @@ -163,6 +168,23 @@ Future setupDependencyInjection() async { ), ); + // Moderation: the blocked-users cache is shared with listings and chat. + getIt.registerSingleton(BlockedUsersCache()); + getIt.registerLazySingleton( + () => SupabaseModerationDataSource(supabaseClient: getIt()), + ); + getIt.registerLazySingleton( + () => ModerationRepositoryImpl( + dataSource: getIt(), + ), + ); + getIt.registerLazySingleton( + () => ModerationViewModel( + repository: getIt(), + cache: getIt(), + ), + ); + // Listings Feature - Data Layer getIt.registerSingleton( SupabaseStorageDataSource(supabaseClient: getIt()), @@ -172,6 +194,7 @@ Future setupDependencyInjection() async { SupabaseListingsDataSource( supabaseClient: getIt(), storageDataSource: getIt(), + blockedUsers: getIt(), ), ); @@ -406,7 +429,10 @@ Future setupDependencyInjection() async { // Chat Feature getIt.registerLazySingleton( - () => SupabaseChatDataSource(supabaseClient: getIt()), + () => SupabaseChatDataSource( + supabaseClient: getIt(), + blockedUsers: getIt(), + ), ); getIt.registerLazySingleton( diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 30b6bc5..c24faf7 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -578,5 +578,36 @@ "@ordersToConfirm": {"placeholders": {"count": {"type": "int"}}}, "ordersToHandOver": "{count, plural, =1{1 sale: hand over the book to get paid} other{{count} sales: hand over the books to get paid}}", "@ordersToHandOver": {"placeholders": {"count": {"type": "int"}}}, - "viewOrders": "View orders" + "viewOrders": "View orders", + "moreOptions": "More options", + "reportListing": "Report listing", + "reportSeller": "Report seller", + "reportUser": "Report user", + "blockUser": "Block user", + "unblockUser": "Unblock", + "reportTitle": "Why are you reporting this?", + "reportReasonSpam": "Spam or misleading", + "reportReasonScam": "Scam or fraud", + "reportReasonInappropriate": "Inappropriate or offensive content", + "reportReasonHarassment": "Harassment or abuse", + "reportReasonProhibited": "Prohibited or counterfeit item", + "reportReasonOther": "Something else", + "reportDetailsHint": "Add details (optional)", + "reportSubmit": "Send report", + "reportSent": "Thanks. Our team will review your report.", + "reportLimitReached": "You have sent too many reports today. Please try again tomorrow.", + "moderationFailed": "That didn't work. Check your connection and try again.", + "blockConfirmTitle": "Block {name}?", + "@blockConfirmTitle": {"placeholders": {"name": {"type": "String"}}}, + "blockConfirmBody": "You won't see their listings or chats, and neither of you can message the other. You can unblock them from your profile.", + "blockConfirm": "Block", + "userBlocked": "{name} is blocked.", + "@userBlocked": {"placeholders": {"name": {"type": "String"}}}, + "userUnblocked": "{name} is unblocked.", + "@userUnblocked": {"placeholders": {"name": {"type": "String"}}}, + "blockedUsersTitle": "Blocked users", + "blockedUsersEmpty": "You haven't blocked anyone.", + "blockedUserFallbackName": "This user", + "chatBlockedBanner": "You blocked this user. Unblock them to send messages.", + "chatSendBlocked": "You can't message this user." } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 7793863..e61664e 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -549,5 +549,33 @@ "ordersNeedAction": "Des commandes attendent votre action", "ordersToConfirm": "{count, plural, =1{1 achat : confirmez la réception du livre} other{{count} achats : confirmez la réception des livres}}", "ordersToHandOver": "{count, plural, =1{1 vente : remettez le livre pour être payé} other{{count} ventes : remettez les livres pour être payé}}", - "viewOrders": "Voir les commandes" + "viewOrders": "Voir les commandes", + "moreOptions": "Plus d'options", + "reportListing": "Signaler l'annonce", + "reportSeller": "Signaler le vendeur", + "reportUser": "Signaler l'utilisateur", + "blockUser": "Bloquer l'utilisateur", + "unblockUser": "Débloquer", + "reportTitle": "Pourquoi signalez-vous ceci ?", + "reportReasonSpam": "Spam ou trompeur", + "reportReasonScam": "Arnaque ou fraude", + "reportReasonInappropriate": "Contenu inapproprié ou offensant", + "reportReasonHarassment": "Harcèlement ou abus", + "reportReasonProhibited": "Article interdit ou contrefait", + "reportReasonOther": "Autre chose", + "reportDetailsHint": "Ajoutez des détails (facultatif)", + "reportSubmit": "Envoyer le signalement", + "reportSent": "Merci. Notre équipe examinera votre signalement.", + "reportLimitReached": "Vous avez envoyé trop de signalements aujourd'hui. Réessayez demain.", + "moderationFailed": "Cela n'a pas fonctionné. Vérifiez votre connexion et réessayez.", + "blockConfirmTitle": "Bloquer {name} ?", + "blockConfirmBody": "Vous ne verrez plus ses annonces ni ses discussions, et aucun de vous ne pourra écrire à l'autre. Vous pouvez le débloquer depuis votre profil.", + "blockConfirm": "Bloquer", + "userBlocked": "{name} est bloqué(e).", + "userUnblocked": "{name} est débloqué(e).", + "blockedUsersTitle": "Utilisateurs bloqués", + "blockedUsersEmpty": "Vous n'avez bloqué personne.", + "blockedUserFallbackName": "Cet utilisateur", + "chatBlockedBanner": "Vous avez bloqué cet utilisateur. Débloquez-le pour envoyer des messages.", + "chatSendBlocked": "Vous ne pouvez pas écrire à cet utilisateur." } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 86821c1..00a3fce 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -3112,6 +3112,174 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'View orders'** String get viewOrders; + + /// No description provided for @moreOptions. + /// + /// In en, this message translates to: + /// **'More options'** + String get moreOptions; + + /// No description provided for @reportListing. + /// + /// In en, this message translates to: + /// **'Report listing'** + String get reportListing; + + /// No description provided for @reportSeller. + /// + /// In en, this message translates to: + /// **'Report seller'** + String get reportSeller; + + /// No description provided for @reportUser. + /// + /// In en, this message translates to: + /// **'Report user'** + String get reportUser; + + /// No description provided for @blockUser. + /// + /// In en, this message translates to: + /// **'Block user'** + String get blockUser; + + /// No description provided for @unblockUser. + /// + /// In en, this message translates to: + /// **'Unblock'** + String get unblockUser; + + /// No description provided for @reportTitle. + /// + /// In en, this message translates to: + /// **'Why are you reporting this?'** + String get reportTitle; + + /// No description provided for @reportReasonSpam. + /// + /// In en, this message translates to: + /// **'Spam or misleading'** + String get reportReasonSpam; + + /// No description provided for @reportReasonScam. + /// + /// In en, this message translates to: + /// **'Scam or fraud'** + String get reportReasonScam; + + /// No description provided for @reportReasonInappropriate. + /// + /// In en, this message translates to: + /// **'Inappropriate or offensive content'** + String get reportReasonInappropriate; + + /// No description provided for @reportReasonHarassment. + /// + /// In en, this message translates to: + /// **'Harassment or abuse'** + String get reportReasonHarassment; + + /// No description provided for @reportReasonProhibited. + /// + /// In en, this message translates to: + /// **'Prohibited or counterfeit item'** + String get reportReasonProhibited; + + /// No description provided for @reportReasonOther. + /// + /// In en, this message translates to: + /// **'Something else'** + String get reportReasonOther; + + /// No description provided for @reportDetailsHint. + /// + /// In en, this message translates to: + /// **'Add details (optional)'** + String get reportDetailsHint; + + /// No description provided for @reportSubmit. + /// + /// In en, this message translates to: + /// **'Send report'** + String get reportSubmit; + + /// No description provided for @reportSent. + /// + /// In en, this message translates to: + /// **'Thanks. Our team will review your report.'** + String get reportSent; + + /// No description provided for @reportLimitReached. + /// + /// In en, this message translates to: + /// **'You have sent too many reports today. Please try again tomorrow.'** + String get reportLimitReached; + + /// No description provided for @moderationFailed. + /// + /// In en, this message translates to: + /// **'That didn\'t work. Check your connection and try again.'** + String get moderationFailed; + + /// No description provided for @blockConfirmTitle. + /// + /// In en, this message translates to: + /// **'Block {name}?'** + String blockConfirmTitle(String name); + + /// No description provided for @blockConfirmBody. + /// + /// In en, this message translates to: + /// **'You won\'t see their listings or chats, and neither of you can message the other. You can unblock them from your profile.'** + String get blockConfirmBody; + + /// No description provided for @blockConfirm. + /// + /// In en, this message translates to: + /// **'Block'** + String get blockConfirm; + + /// No description provided for @userBlocked. + /// + /// In en, this message translates to: + /// **'{name} is blocked.'** + String userBlocked(String name); + + /// No description provided for @userUnblocked. + /// + /// In en, this message translates to: + /// **'{name} is unblocked.'** + String userUnblocked(String name); + + /// No description provided for @blockedUsersTitle. + /// + /// In en, this message translates to: + /// **'Blocked users'** + String get blockedUsersTitle; + + /// No description provided for @blockedUsersEmpty. + /// + /// In en, this message translates to: + /// **'You haven\'t blocked anyone.'** + String get blockedUsersEmpty; + + /// No description provided for @blockedUserFallbackName. + /// + /// In en, this message translates to: + /// **'This user'** + String get blockedUserFallbackName; + + /// No description provided for @chatBlockedBanner. + /// + /// In en, this message translates to: + /// **'You blocked this user. Unblock them to send messages.'** + String get chatBlockedBanner; + + /// No description provided for @chatSendBlocked. + /// + /// In en, this message translates to: + /// **'You can\'t message this user.'** + String get chatSendBlocked; } class _AppLocalizationsDelegate extends LocalizationsDelegate { diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 5d55208..be79e14 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1577,4 +1577,94 @@ class AppLocalizationsEn extends AppLocalizations { @override String get viewOrders => 'View orders'; + + @override + String get moreOptions => 'More options'; + + @override + String get reportListing => 'Report listing'; + + @override + String get reportSeller => 'Report seller'; + + @override + String get reportUser => 'Report user'; + + @override + String get blockUser => 'Block user'; + + @override + String get unblockUser => 'Unblock'; + + @override + String get reportTitle => 'Why are you reporting this?'; + + @override + String get reportReasonSpam => 'Spam or misleading'; + + @override + String get reportReasonScam => 'Scam or fraud'; + + @override + String get reportReasonInappropriate => 'Inappropriate or offensive content'; + + @override + String get reportReasonHarassment => 'Harassment or abuse'; + + @override + String get reportReasonProhibited => 'Prohibited or counterfeit item'; + + @override + String get reportReasonOther => 'Something else'; + + @override + String get reportDetailsHint => 'Add details (optional)'; + + @override + String get reportSubmit => 'Send report'; + + @override + String get reportSent => 'Thanks. Our team will review your report.'; + + @override + String get reportLimitReached => 'You have sent too many reports today. Please try again tomorrow.'; + + @override + String get moderationFailed => 'That didn\'t work. Check your connection and try again.'; + + @override + String blockConfirmTitle(String name) { + return 'Block $name?'; + } + + @override + String get blockConfirmBody => 'You won\'t see their listings or chats, and neither of you can message the other. You can unblock them from your profile.'; + + @override + String get blockConfirm => 'Block'; + + @override + String userBlocked(String name) { + return '$name is blocked.'; + } + + @override + String userUnblocked(String name) { + return '$name is unblocked.'; + } + + @override + String get blockedUsersTitle => 'Blocked users'; + + @override + String get blockedUsersEmpty => 'You haven\'t blocked anyone.'; + + @override + String get blockedUserFallbackName => 'This user'; + + @override + String get chatBlockedBanner => 'You blocked this user. Unblock them to send messages.'; + + @override + String get chatSendBlocked => 'You can\'t message this user.'; } diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 35a1e14..9316dd0 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -1577,4 +1577,94 @@ class AppLocalizationsFr extends AppLocalizations { @override String get viewOrders => 'Voir les commandes'; + + @override + String get moreOptions => 'Plus d\'options'; + + @override + String get reportListing => 'Signaler l\'annonce'; + + @override + String get reportSeller => 'Signaler le vendeur'; + + @override + String get reportUser => 'Signaler l\'utilisateur'; + + @override + String get blockUser => 'Bloquer l\'utilisateur'; + + @override + String get unblockUser => 'Débloquer'; + + @override + String get reportTitle => 'Pourquoi signalez-vous ceci ?'; + + @override + String get reportReasonSpam => 'Spam ou trompeur'; + + @override + String get reportReasonScam => 'Arnaque ou fraude'; + + @override + String get reportReasonInappropriate => 'Contenu inapproprié ou offensant'; + + @override + String get reportReasonHarassment => 'Harcèlement ou abus'; + + @override + String get reportReasonProhibited => 'Article interdit ou contrefait'; + + @override + String get reportReasonOther => 'Autre chose'; + + @override + String get reportDetailsHint => 'Ajoutez des détails (facultatif)'; + + @override + String get reportSubmit => 'Envoyer le signalement'; + + @override + String get reportSent => 'Merci. Notre équipe examinera votre signalement.'; + + @override + String get reportLimitReached => 'Vous avez envoyé trop de signalements aujourd\'hui. Réessayez demain.'; + + @override + String get moderationFailed => 'Cela n\'a pas fonctionné. Vérifiez votre connexion et réessayez.'; + + @override + String blockConfirmTitle(String name) { + return 'Bloquer $name ?'; + } + + @override + String get blockConfirmBody => 'Vous ne verrez plus ses annonces ni ses discussions, et aucun de vous ne pourra écrire à l\'autre. Vous pouvez le débloquer depuis votre profil.'; + + @override + String get blockConfirm => 'Bloquer'; + + @override + String userBlocked(String name) { + return '$name est bloqué(e).'; + } + + @override + String userUnblocked(String name) { + return '$name est débloqué(e).'; + } + + @override + String get blockedUsersTitle => 'Utilisateurs bloqués'; + + @override + String get blockedUsersEmpty => 'Vous n\'avez bloqué personne.'; + + @override + String get blockedUserFallbackName => 'Cet utilisateur'; + + @override + String get chatBlockedBanner => 'Vous avez bloqué cet utilisateur. Débloquez-le pour envoyer des messages.'; + + @override + String get chatSendBlocked => 'Vous ne pouvez pas écrire à cet utilisateur.'; } diff --git a/lib/main.dart b/lib/main.dart index 69adb80..3304f76 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,5 +1,6 @@ import 'package:book_bridge/features/transactions/presentation/viewmodels/pending_orders_viewmodel.dart'; import 'package:flutter/material.dart'; +import 'package:book_bridge/features/moderation/presentation/viewmodels/moderation_viewmodel.dart'; import 'package:book_bridge/l10n/app_localizations.dart'; import 'package:provider/provider.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; @@ -114,6 +115,9 @@ class MyApp extends StatelessWidget { ChangeNotifierProvider.value( value: di.getIt(), ), + ChangeNotifierProvider.value( + value: di.getIt(), + ), ], child: Consumer2( // Changed to Consumer2 diff --git a/pubspec.yaml b/pubspec.yaml index 04bdd46..f51c2e0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: book_bridge description: "A peer-to-peer marketplace for Cameroonian students to buy and sell used physical books." publish_to: "none" -version: 1.6.2+8 +version: 1.6.3+9 environment: sdk: ^3.10.7 diff --git a/supabase/migrations/20261019000000_reports_and_blocks.sql b/supabase/migrations/20261019000000_reports_and_blocks.sql new file mode 100644 index 0000000..85ce7c7 --- /dev/null +++ b/supabase/migrations/20261019000000_reports_and_blocks.sql @@ -0,0 +1,157 @@ +-- Report and block (Google Play user-generated content policy). +-- +-- user_blocks: a user hides another user's listings and chats. Blocking +-- also stops new messages in both directions (trigger). +-- content_reports: a user reports a listing or a user. Only admins act on +-- reports, through the Rust core (connects as postgres). + +-- 1. Blocks -------------------------------------------------------------------- +create table if not exists public.user_blocks ( + blocker_id uuid not null references auth.users (id) on delete cascade, + blocked_id uuid not null references auth.users (id) on delete cascade, + created_at timestamptz not null default now(), + primary key (blocker_id, blocked_id), + constraint user_blocks_not_self check (blocker_id <> blocked_id) +); + +create index if not exists user_blocks_blocked_id_idx + on public.user_blocks (blocked_id); + +alter table public.user_blocks enable row level security; + +revoke all on public.user_blocks from anon, authenticated; +grant select, insert, delete on public.user_blocks to authenticated; + +drop policy if exists "user_blocks_select_own" on public.user_blocks; +create policy "user_blocks_select_own" on public.user_blocks + for select to authenticated + using (blocker_id = auth.uid()); + +drop policy if exists "user_blocks_insert_own" on public.user_blocks; +create policy "user_blocks_insert_own" on public.user_blocks + for insert to authenticated + with check (blocker_id = auth.uid()); + +drop policy if exists "user_blocks_delete_own" on public.user_blocks; +create policy "user_blocks_delete_own" on public.user_blocks + for delete to authenticated + using (blocker_id = auth.uid()); + +-- No new messages between two users when either has blocked the other. A +-- trigger works whether or not RLS is enabled on messages and does not +-- interact with its existing policies. +create or replace function public.reject_blocked_message() +returns trigger +language plpgsql +security definer +set search_path = '' +as $$ +begin + if exists ( + select 1 from public.user_blocks b + where (b.blocker_id = new.sender_id and b.blocked_id = new.receiver_id) + or (b.blocker_id = new.receiver_id and b.blocked_id = new.sender_id) + ) then + raise exception 'blocked: messages between these users are blocked' + using errcode = 'P0001'; + end if; + return new; +end; +$$; + +revoke all on function public.reject_blocked_message() from public, anon, authenticated; + +drop trigger if exists messages_reject_blocked on public.messages; +create trigger messages_reject_blocked + before insert on public.messages + for each row execute function public.reject_blocked_message(); + +-- 2. Reports ------------------------------------------------------------------- +-- reporter_id and reported_user_id are cleared when an account is deleted, +-- so neither is NOT NULL. "At least one target" is enforced on insert. +create table if not exists public.content_reports ( + id uuid primary key default gen_random_uuid(), + reporter_id uuid references auth.users (id) on delete set null, + listing_id uuid references public.listings (id) on delete set null, + reported_user_id uuid references auth.users (id) on delete set null, + reason text not null check (reason in ( + 'spam', 'scam', 'inappropriate', 'harassment', + 'prohibited', 'other')), + details text check (char_length(details) <= 500), + status text not null default 'open' + check (status in ('open', 'actioned', 'dismissed')), + created_at timestamptz not null default now(), + reviewed_by uuid references auth.users (id) on delete set null, + reviewed_at timestamptz +); + +create index if not exists content_reports_open_idx + on public.content_reports (created_at) + where status = 'open'; + +-- One open report per reporter and target; repeats are no-ops for the app. +create unique index if not exists content_reports_open_once + on public.content_reports ( + reporter_id, + coalesce(listing_id, '00000000-0000-0000-0000-000000000000'::uuid), + coalesce(reported_user_id, '00000000-0000-0000-0000-000000000000'::uuid)) + where status = 'open'; + +alter table public.content_reports enable row level security; + +revoke all on public.content_reports from anon, authenticated; +grant insert (listing_id, reported_user_id, reason, details) + on public.content_reports to authenticated; + +drop policy if exists "content_reports_insert_own" on public.content_reports; +create policy "content_reports_insert_own" on public.content_reports + for insert to authenticated + with check ( + reporter_id = auth.uid() + and status = 'open' + and reviewed_by is null + and reviewed_at is null + and num_nonnulls(listing_id, reported_user_id) >= 1 + and reported_user_id is distinct from auth.uid() + ); + +-- reporter_id comes from the session, never from the client, and each user +-- may file at most 20 reports a day. +create or replace function public.prepare_content_report() +returns trigger +language plpgsql +security definer +set search_path = '' +as $$ +begin + if auth.uid() is not null then + new.reporter_id := auth.uid(); + if (select count(*) from public.content_reports r + where r.reporter_id = new.reporter_id + and r.created_at > now() - interval '1 day') >= 20 then + raise exception 'report_limit: too many reports today' + using errcode = 'P0001'; + end if; + end if; + return new; +end; +$$; + +revoke all on function public.prepare_content_report() from public, anon, authenticated; + +drop trigger if exists content_reports_prepare on public.content_reports; +create trigger content_reports_prepare + before insert on public.content_reports + for each row execute function public.prepare_content_report(); + +-- 3. Admin audit trail --------------------------------------------------------- +alter table public.admin_actions + add column if not exists report_id uuid references public.content_reports (id) on delete set null; + +alter table public.admin_actions drop constraint if exists admin_actions_action_check; +alter table public.admin_actions + add constraint admin_actions_action_check check (action in ( + 'release_dispute', 'refund_dispute', + 'refund_unmatched', 'dismiss_unmatched', + 'approve_id', 'reject_id', + 'remove_reported_listing', 'dismiss_report')); diff --git a/test/features/admin/data/datasources/rust_admin_data_source_test.dart b/test/features/admin/data/datasources/rust_admin_data_source_test.dart index fc6c777..bc396db 100644 --- a/test/features/admin/data/datasources/rust_admin_data_source_test.dart +++ b/test/features/admin/data/datasources/rust_admin_data_source_test.dart @@ -276,6 +276,67 @@ void main() { }); }); + group('content reports', () { + const reportId = '33333333-2222-4333-8444-555555555555'; + + test('reports parses the list', () async { + final ds = build( + (_) => ok({ + 'reports': [ + { + 'id': reportId, + 'reason': 'scam', + 'details': 'Asked for payment upfront', + 'created_at': '2026-10-19T10:00:00Z', + 'reporter_name': 'Ama', + 'listing_id': 'listing-1', + 'listing_title': 'Physics F5', + 'listing_status': 'available', + 'reported_user_id': 'seller-1', + 'reported_user_name': 'Bob', + }, + ], + }), + ); + + final reports = await ds.reports(); + + expect(requests.single.url.path, '/admin/reports'); + final report = reports.single; + expect(report.id, reportId); + expect(report.reason, 'scam'); + expect(report.isAboutListing, isTrue); + expect(report.listingTitle, 'Physics F5'); + expect(report.reportedUserName, 'Bob'); + expect(report.createdAt, isNotNull); + }); + + test('reports rejects a report without an id', () async { + final ds = build( + (_) => ok({ + 'reports': [ + {'reason': 'spam'}, + ], + }), + ); + + await expectLater(ds.reports(), throwsA(isA())); + }); + + test('dismiss and remove-listing POST the note', () async { + final ds = build((_) => ok({'ok': true})); + + await ds.dismissReport(reportId, 'Not a violation'); + await ds.removeReportedListing(reportId, 'Counterfeit'); + + expect(requests[0].method, 'POST'); + expect(requests[0].url.path, '/admin/reports/$reportId/dismiss'); + expect(jsonDecode(requests[0].body), {'note': 'Not a violation'}); + expect(requests[1].url.path, '/admin/reports/$reportId/remove-listing'); + expect(jsonDecode(requests[1].body), {'note': 'Counterfeit'}); + }); + }); + test('a 409 from a second click surfaces the server message', () async { final ds = build( (_) => http.Response( diff --git a/test/features/admin/presentation/viewmodels/admin_viewmodel_test.dart b/test/features/admin/presentation/viewmodels/admin_viewmodel_test.dart index 7ec9a86..17c729b 100644 --- a/test/features/admin/presentation/viewmodels/admin_viewmodel_test.dart +++ b/test/features/admin/presentation/viewmodels/admin_viewmodel_test.dart @@ -79,6 +79,34 @@ class _FakeRepo implements AdminRepository { @override Future> idPhotoUrl(String path) async => photoResult; + + List reportList = [ + ContentReport( + id: 'r1', + reason: 'scam', + createdAt: DateTime(2026), + listingId: 'l1', + ), + ]; + + @override + Future>> reports() async => + Right(reportList); + + @override + Future> dismissReport(String id, String note) async { + calls.add('dismissReport $id $note'); + return actionResult; + } + + @override + Future> removeReportedListing( + String id, + String note, + ) async { + calls.add('removeReportedListing $id $note'); + return actionResult; + } } void main() { @@ -177,6 +205,38 @@ void main() { }); }); + group('content reports', () { + test('open loads open reports', () async { + await vm.open(); + + expect(vm.reports.single.id, 'r1'); + }); + + test('removeReportedListing calls the repository and reloads', () async { + await vm.open(); + repo.reportList = const []; + + final error = await vm.removeReportedListing('r1', 'Counterfeit'); + + expect(error, isNull); + expect(repo.calls, contains('removeReportedListing r1 Counterfeit')); + expect(vm.reports, isEmpty); + expect(vm.busyId, isNull); + }); + + test('dismissReport returns the server message on failure', () async { + await vm.open(); + repo.actionResult = const Left( + ServerFailure(message: 'Report is no longer open'), + ); + + final error = await vm.dismissReport('r1', 'Not a violation'); + + expect(error, 'Report is no longer open'); + expect(repo.calls, contains('dismissReport r1 Not a violation')); + }); + }); + group('dialog validation', () { test('note is required and capped at 1000 characters', () { expect(validateAdminNote(null), isNotNull); diff --git a/test/features/chat/data/datasources/supabase_chat_data_source_test.dart b/test/features/chat/data/datasources/supabase_chat_data_source_test.dart new file mode 100644 index 0000000..a16c9b9 --- /dev/null +++ b/test/features/chat/data/datasources/supabase_chat_data_source_test.dart @@ -0,0 +1,94 @@ +import 'package:book_bridge/core/error/exceptions.dart'; +import 'package:book_bridge/features/chat/data/datasources/supabase_chat_data_source.dart'; +import 'package:book_bridge/features/moderation/domain/blocked_users_cache.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; + +import '../../../../helpers/fake_supabase.dart'; + +void main() { + late List requests; + + Map message(String id, String otherUserId) => { + 'id': id, + 'listing_id': 'l1', + 'sender_id': otherUserId, + 'receiver_id': fakeUserId, + 'content': 'hi from $otherUserId', + 'is_read': false, + 'created_at': '2026-10-19T10:00:00Z', + 'listings': {'id': 'l1', 'title': 'Physics', 'image_url': null}, + }; + + Future build( + http.Response Function(http.Request) respond, { + BlockedUsersCache? blocked, + }) async { + requests = []; + final client = await fakeSupabaseClient( + recordingHttpClient(requests, respond), + ); + return SupabaseChatDataSource( + supabaseClient: client, + blockedUsers: blocked, + ); + } + + test('getConversations hides blocked users', () async { + final blocked = BlockedUsersCache()..add('u3'); + final ds = await build((request) { + final path = request.url.path; + final select = request.url.queryParameters['select'] ?? ''; + if (path.endsWith('/public_profiles')) { + return jsonResponse([ + {'id': 'u2', 'full_name': 'Bob', 'avatar_url': null}, + ]); + } + if (select.contains('listings')) { + return jsonResponse([message('m1', 'u2'), message('m2', 'u3')]); + } + return jsonResponse([]); + }, blocked: blocked); + + final conversations = await ds.getConversations(); + + expect(conversations.map((c) => c.otherUserId), ['u2']); + final profiles = requests.firstWhere( + (r) => r.url.path.endsWith('/public_profiles'), + ); + expect(profiles.url.queryParameters['id'], 'in.("u2")'); + }); + + group('sendMessage', () { + test('maps the block trigger to MessagingBlockedException', () async { + final ds = await build( + (_) => jsonResponse({ + 'code': 'P0001', + 'message': 'blocked: messages between these users are blocked', + }, 400), + ); + + await expectLater( + ds.sendMessage(listingId: 'l1', receiverId: 'u2', content: 'hi'), + throwsA(isA()), + ); + }); + + test('other failures stay ServerException', () async { + final ds = await build( + (_) => jsonResponse({'code': '42501', 'message': 'denied'}, 403), + ); + + await expectLater( + ds.sendMessage(listingId: 'l1', receiverId: 'u2', content: 'hi'), + throwsA( + isA().having( + (e) => e, + 'not blocked', + isNot(isA()), + ), + ), + ); + }); + }); +} diff --git a/test/features/chat/presentation/viewmodels/chat_viewmodel_test.dart b/test/features/chat/presentation/viewmodels/chat_viewmodel_test.dart index 97d6160..2f816d0 100644 --- a/test/features/chat/presentation/viewmodels/chat_viewmodel_test.dart +++ b/test/features/chat/presentation/viewmodels/chat_viewmodel_test.dart @@ -1,3 +1,4 @@ +import 'package:book_bridge/core/error/exceptions.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:book_bridge/features/chat/domain/repositories/chat_repository.dart'; @@ -121,6 +122,26 @@ void main() { // Assert expect(viewModel.isSending, false); expect(viewModel.sendError, isNotNull); + expect(viewModel.sendBlocked, isFalse); + }); + + test('flags a send refused because of a block', () async { + when( + () => mockRepository.sendMessage( + listingId: any(named: 'listingId'), + receiverId: any(named: 'receiverId'), + content: any(named: 'content'), + ), + ).thenThrow(MessagingBlockedException(message: 'blocked')); + + await viewModel.sendMessage( + listingId: tListingId, + receiverId: tReceiverId, + content: tContent, + ); + + expect(viewModel.sendBlocked, isTrue); + expect(viewModel.sendError, isNotNull); }); }); } diff --git a/test/features/listings/data/datasources/blocked_sellers_filter_test.dart b/test/features/listings/data/datasources/blocked_sellers_filter_test.dart new file mode 100644 index 0000000..6b6fef4 --- /dev/null +++ b/test/features/listings/data/datasources/blocked_sellers_filter_test.dart @@ -0,0 +1,74 @@ +import 'package:book_bridge/features/listings/data/datasources/supabase_listings_data_source.dart'; +import 'package:book_bridge/features/listings/data/datasources/supabase_storage_data_source.dart'; +import 'package:book_bridge/features/moderation/domain/blocked_users_cache.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; + +import '../../../../helpers/fake_supabase.dart'; + +void main() { + late List requests; + late BlockedUsersCache blocked; + late SupabaseListingsDataSource dataSource; + + setUp(() async { + requests = []; + blocked = BlockedUsersCache(); + final client = await fakeSupabaseClient( + recordingHttpClient(requests, (_) => jsonResponse([])), + ); + dataSource = SupabaseListingsDataSource( + supabaseClient: client, + storageDataSource: SupabaseStorageDataSource(supabaseClient: client), + blockedUsers: blocked, + ); + }); + + group('getListings', () { + test('does not filter sellers when nobody is blocked', () async { + await dataSource.getListings(); + + expect(requests.single.url.queryParameters, isNot(contains('seller_id'))); + }); + + test('hides blocked sellers', () async { + blocked.replaceAll(['u2', 'u3']); + + await dataSource.getListings(); + + expect( + requests.single.url.queryParameters['seller_id'], + 'not.in.("u2","u3")', + ); + }); + }); + + group('searchListings', () { + test('keeps the caller limit when nothing is filtered', () async { + await dataSource.searchListings('physics', limit: 20); + + final request = requests.single; + expect(request.url.queryParameters, isNot(contains('seller_id'))); + expect(request.body, contains('"_limit":20')); + }); + + test('hides blocked sellers and widens the search window', () async { + blocked.add('u2'); + + await dataSource.searchListings('physics', limit: 20); + + final request = requests.single; + expect(request.url.queryParameters['seller_id'], 'not.in.("u2")'); + expect(request.body, contains('"_limit":500')); + expect(request.url.queryParameters['limit'], '20'); + }); + }); + + test("a seller's own page is never filtered", () async { + blocked.add('u2'); + + await dataSource.getListingsBySeller('u2'); + + expect(requests.single.url.queryParameters['seller_id'], 'eq.u2'); + }); +} diff --git a/test/features/moderation/data/supabase_moderation_data_source_test.dart b/test/features/moderation/data/supabase_moderation_data_source_test.dart new file mode 100644 index 0000000..b79029b --- /dev/null +++ b/test/features/moderation/data/supabase_moderation_data_source_test.dart @@ -0,0 +1,178 @@ +import 'dart:convert'; + +import 'package:book_bridge/core/error/exceptions.dart'; +import 'package:book_bridge/features/moderation/data/datasources/supabase_moderation_data_source.dart'; +import 'package:book_bridge/features/moderation/domain/entities/report_reason.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; + +import '../../../helpers/fake_supabase.dart'; + +void main() { + late List requests; + + Future build( + http.Response Function(http.Request) respond, { + bool signedIn = true, + }) async { + requests = []; + final client = await fakeSupabaseClient( + recordingHttpClient(requests, respond), + signedIn: signedIn, + ); + return SupabaseModerationDataSource(supabaseClient: client); + } + + const json = jsonResponse; + + http.Response pgError(String code, String message) => + json({'code': code, 'message': message}, 400); + + group('getBlockedUsers', () { + test('loads blocks then their public profiles', () async { + final ds = await build((request) { + if (request.url.path.endsWith('/user_blocks')) { + return json([ + {'blocked_id': 'u2'}, + {'blocked_id': 'u3'}, + ]); + } + return json([ + {'id': 'u3', 'full_name': 'Carol', 'avatar_url': null}, + {'id': 'u2', 'full_name': 'Bob', 'avatar_url': 'https://a/b.png'}, + ]); + }); + + final users = await ds.getBlockedUsers(); + + expect(users.map((u) => u.id), ['u2', 'u3']); + expect(users.first.name, 'Bob'); + expect(users.first.avatarUrl, 'https://a/b.png'); + expect(requests[0].url.queryParameters['blocker_id'], 'eq.$fakeUserId'); + expect(requests[1].url.path, endsWith('/public_profiles')); + expect(requests[1].url.queryParameters['id'], 'in.("u2","u3")'); + }); + + test('skips the profile lookup when nobody is blocked', () async { + final ds = await build((_) => json([])); + + expect(await ds.getBlockedUsers(), isEmpty); + expect(requests, hasLength(1)); + }); + + test('throws when signed out', () async { + final ds = await build((_) => json([]), signedIn: false); + + await expectLater(ds.getBlockedUsers(), throwsA(isA())); + expect(requests, isEmpty); + }); + }); + + group('blockUser', () { + test('inserts a block for the current user', () async { + final ds = await build((_) => http.Response('', 201)); + + await ds.blockUser('u2'); + + expect(requests.single.method, 'POST'); + expect(jsonDecode(requests.single.body), { + 'blocker_id': fakeUserId, + 'blocked_id': 'u2', + }); + }); + + test('treats an existing block as success', () async { + final ds = await build((_) => pgError('23505', 'duplicate key')); + + await ds.blockUser('u2'); + }); + + test('surfaces other errors', () async { + final ds = await build((_) => pgError('42501', 'permission denied')); + + await expectLater(ds.blockUser('u2'), throwsA(isA())); + }); + }); + + test('unblockUser deletes only the current user\'s block', () async { + final ds = await build((_) => http.Response('', 204)); + + await ds.unblockUser('u2'); + + final request = requests.single; + expect(request.method, 'DELETE'); + expect(request.url.queryParameters['blocker_id'], 'eq.$fakeUserId'); + expect(request.url.queryParameters['blocked_id'], 'eq.u2'); + }); + + group('report', () { + test('sends only the listing for a listing report', () async { + final ds = await build((_) => http.Response('', 201)); + + await ds.report( + listingId: 'l1', + reason: ReportReason.scam, + details: ' Asked for money first ', + ); + + final request = requests.single; + expect(request.method, 'POST'); + expect(request.url.path, endsWith('/content_reports')); + // The client has no SELECT on reports, so it must not ask for rows. + expect(request.headers['Prefer'] ?? '', isNot(contains('return=rep'))); + expect(jsonDecode(request.body), { + 'listing_id': 'l1', + 'reason': 'scam', + 'details': 'Asked for money first', + }); + }); + + test('omits empty details and caps long ones', () async { + final ds = await build((_) => http.Response('', 201)); + + await ds.report(reportedUserId: 'u2', reason: ReportReason.other); + await ds.report( + reportedUserId: 'u2', + reason: ReportReason.harassment, + details: 'x' * 600, + ); + + expect(jsonDecode(requests[0].body), { + 'reported_user_id': 'u2', + 'reason': 'other', + }); + expect( + (jsonDecode(requests[1].body)['details'] as String).length, + SupabaseModerationDataSource.maxDetailsLength, + ); + }); + + test('treats a duplicate open report as success', () async { + final ds = await build((_) => pgError('23505', 'duplicate key')); + + await ds.report(listingId: 'l1', reason: ReportReason.spam); + }); + + test('maps the daily limit to ReportLimitException', () async { + final ds = await build( + (_) => pgError('P0001', 'report_limit: too many reports today'), + ); + + await expectLater( + ds.report(listingId: 'l1', reason: ReportReason.spam), + throwsA(isA()), + ); + }); + }); + + test('ReportReason wire values match the database check', () { + expect(ReportReason.values.map((r) => r.wire), [ + 'spam', + 'scam', + 'inappropriate', + 'harassment', + 'prohibited', + 'other', + ]); + }); +} diff --git a/test/features/moderation/presentation/moderation_viewmodel_test.dart b/test/features/moderation/presentation/moderation_viewmodel_test.dart new file mode 100644 index 0000000..e08c28d --- /dev/null +++ b/test/features/moderation/presentation/moderation_viewmodel_test.dart @@ -0,0 +1,178 @@ +import 'package:book_bridge/core/error/failures.dart'; +import 'package:book_bridge/features/moderation/domain/blocked_users_cache.dart'; +import 'package:book_bridge/features/moderation/domain/entities/blocked_user.dart'; +import 'package:book_bridge/features/moderation/domain/entities/moderation_failure.dart'; +import 'package:book_bridge/features/moderation/domain/entities/report_reason.dart'; +import 'package:book_bridge/features/moderation/domain/repositories/moderation_repository.dart'; +import 'package:book_bridge/features/moderation/presentation/viewmodels/moderation_viewmodel.dart'; +import 'package:dartz/dartz.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +class _MockRepository extends Mock implements ModerationRepository {} + +const _bob = BlockedUser(id: 'u2', name: 'Bob'); +const _carol = BlockedUser(id: 'u3', name: 'Carol'); +const _failure = ModerationFailure(message: 'offline'); + +void main() { + late _MockRepository repository; + late BlockedUsersCache cache; + late ModerationViewModel vm; + + setUpAll(() => registerFallbackValue(ReportReason.spam)); + + setUp(() { + repository = _MockRepository(); + cache = BlockedUsersCache(); + vm = ModerationViewModel(repository: repository, cache: cache); + }); + + void stubBlocked(List users) => when( + () => repository.getBlockedUsers(), + ).thenAnswer((_) async => Right(users)); + + group('load', () { + test('fills the list and the shared cache', () async { + stubBlocked([_bob]); + + final changed = await vm.load('me'); + + expect(changed, isTrue); + expect(vm.blockedUsers, [_bob]); + expect(cache.ids, {'u2'}); + expect(vm.isBlocked('u2'), isTrue); + expect(vm.isLoading, isFalse); + }); + + test('reports no change when the blocks are the same', () async { + stubBlocked([_bob]); + await vm.load('me'); + + expect(await vm.load('me'), isFalse); + }); + + test('keeps the previous blocks when loading fails', () async { + stubBlocked([_bob]); + await vm.load('me'); + when( + () => repository.getBlockedUsers(), + ).thenAnswer((_) async => const Left(_failure)); + + final changed = await vm.load('me'); + + expect(changed, isFalse); + expect(vm.loadFailure, _failure); + expect(cache.ids, {'u2'}); + }); + + test("switching accounts drops the previous account's blocks", () async { + stubBlocked([_bob]); + await vm.load('me'); + when(() => repository.getBlockedUsers()).thenAnswer((_) async { + // The old blocks are gone before the new ones arrive. + expect(cache.isEmpty, isTrue); + return const Right([_carol]); + }); + + await vm.load('someone-else'); + + expect(cache.ids, {'u3'}); + }); + + test('signed out clears without calling the server', () async { + stubBlocked([_bob]); + await vm.load('me'); + + expect(await vm.load(null), isTrue); + expect(cache.isEmpty, isTrue); + verify(() => repository.getBlockedUsers()).called(1); + }); + + test('reset clears without notifying', () async { + stubBlocked([_bob]); + await vm.load('me'); + var notified = false; + vm.addListener(() => notified = true); + + vm.reset(); + + expect(cache.isEmpty, isTrue); + expect(vm.blockedUsers, isEmpty); + expect(notified, isFalse); + }); + }); + + group('block and unblock', () { + test('block adds the user once', () async { + when( + () => repository.blockUser('u2'), + ).thenAnswer((_) async => const Right(unit)); + + expect(await vm.block(_bob), isNull); + expect(await vm.block(_bob), isNull); + + expect(vm.blockedUsers, [_bob]); + expect(cache.ids, {'u2'}); + }); + + test('block failure leaves the list alone', () async { + when( + () => repository.blockUser('u2'), + ).thenAnswer((_) async => const Left(_failure)); + + expect(await vm.block(_bob), _failure); + expect(cache.isEmpty, isTrue); + }); + + test('unblock removes the user', () async { + stubBlocked([_bob, _carol]); + await vm.load('me'); + when( + () => repository.unblockUser('u2'), + ).thenAnswer((_) async => const Right(unit)); + + expect(await vm.unblock('u2'), isNull); + + expect(vm.blockedUsers, [_carol]); + expect(cache.ids, {'u3'}); + }); + }); + + group('report', () { + test('reportListing passes the reason and details', () async { + when( + () => repository.reportListing( + any(), + any(), + details: any(named: 'details'), + ), + ).thenAnswer((_) async => const Right(unit)); + + final failure = await vm.reportListing( + 'l1', + ReportReason.scam, + details: 'fake', + ); + + expect(failure, isNull); + verify( + () => + repository.reportListing('l1', ReportReason.scam, details: 'fake'), + ).called(1); + }); + + test('reportUser returns the failure', () async { + const limit = ModerationFailure( + message: 'limit', + kind: ModerationFailureKind.reportLimit, + ); + when( + () => + repository.reportUser(any(), any(), details: any(named: 'details')), + ).thenAnswer((_) async => const Left(limit)); + + expect(await vm.reportUser('u2', ReportReason.harassment), limit); + }); + }); +} diff --git a/test/features/moderation/presentation/report_sheet_test.dart b/test/features/moderation/presentation/report_sheet_test.dart new file mode 100644 index 0000000..3ea2037 --- /dev/null +++ b/test/features/moderation/presentation/report_sheet_test.dart @@ -0,0 +1,100 @@ +import 'package:book_bridge/core/error/failures.dart'; +import 'package:book_bridge/features/moderation/domain/entities/moderation_failure.dart'; +import 'package:book_bridge/features/moderation/domain/entities/report_reason.dart'; +import 'package:book_bridge/features/moderation/presentation/widgets/report_sheet.dart'; +import 'package:book_bridge/l10n/app_localizations.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + late List<(ReportReason, String?)> submitted; + bool? sent; + + Future open(WidgetTester tester, {Failure? result}) async { + submitted = []; + sent = null; + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) => Scaffold( + body: TextButton( + onPressed: () async { + sent = await showReportSheet( + context, + title: 'Report listing', + onSubmit: (reason, details) async { + submitted.add((reason, details)); + return result; + }, + ); + }, + child: const Text('open'), + ), + ), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + } + + Finder sendButton() => find.widgetWithText(FilledButton, 'Send report'); + + Future send(WidgetTester tester) async { + await tester.ensureVisible(sendButton()); + await tester.pumpAndSettle(); + await tester.tap(sendButton()); + await tester.pumpAndSettle(); + } + + testWidgets('needs a reason before sending', (tester) async { + await open(tester); + + expect(tester.widget(sendButton()).onPressed, isNull); + + await tester.tap(find.text('Scam or fraud')); + await tester.pump(); + + expect(tester.widget(sendButton()).onPressed, isNotNull); + }); + + testWidgets('sends the reason and details, then closes', (tester) async { + await open(tester); + + await tester.tap(find.text('Scam or fraud')); + await tester.enterText(find.byType(TextField), 'Wants money first'); + await send(tester); + + expect(submitted, [(ReportReason.scam, 'Wants money first')]); + expect(sent, isTrue); + expect(find.byType(ReportSheet), findsNothing); + }); + + testWidgets('stays open and explains the daily limit', (tester) async { + await open( + tester, + result: const ModerationFailure( + message: 'limit', + kind: ModerationFailureKind.reportLimit, + ), + ); + + await tester.tap(find.text('Spam or misleading')); + await send(tester); + + expect(find.byType(ReportSheet), findsOneWidget); + expect(find.textContaining('too many reports'), findsOneWidget); + expect(sent, isNull); + }); + + testWidgets('shows a generic error for other failures', (tester) async { + await open(tester, result: const ServerFailure(message: 'boom')); + + await tester.tap(find.text('Something else')); + await send(tester); + + expect(find.textContaining("didn't work"), findsOneWidget); + }); +} diff --git a/test/helpers/fake_supabase.dart b/test/helpers/fake_supabase.dart new file mode 100644 index 0000000..c5eaba5 --- /dev/null +++ b/test/helpers/fake_supabase.dart @@ -0,0 +1,72 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +const fakeUserId = 'aaaaaaaa-1111-4222-8333-444444444444'; + +/// A real [SupabaseClient] whose HTTP goes to [httpClient], optionally +/// signed in as [fakeUserId] without touching the network. Lets tests check +/// the PostgREST requests a data source builds. +Future fakeSupabaseClient( + http.Client httpClient, { + bool signedIn = true, +}) async { + final client = SupabaseClient( + 'https://test.supabase.co', + 'test-anon-key', + httpClient: httpClient, + authOptions: const AuthClientOptions(autoRefreshToken: false), + ); + if (signedIn) { + final exp = DateTime.now().add(const Duration(hours: 1)); + final expSeconds = exp.millisecondsSinceEpoch ~/ 1000; + String part(Map json) => + base64Url.encode(utf8.encode(jsonEncode(json))).replaceAll('=', ''); + final token = + '${part({'alg': 'HS256', 'typ': 'JWT'})}.' + '${part({'sub': fakeUserId, 'exp': expSeconds, 'role': 'authenticated'})}.' + 'signature'; + await client.auth.recoverSession( + jsonEncode({ + 'access_token': token, + 'token_type': 'bearer', + 'expires_in': 3600, + 'expires_at': expSeconds, + 'refresh_token': 'refresh', + 'user': { + 'id': fakeUserId, + 'aud': 'authenticated', + 'app_metadata': {}, + 'user_metadata': {}, + 'created_at': '2026-01-01T00:00:00Z', + }, + }), + ); + } + return client; +} + +/// An HTTP client that records each request into [requests] and answers +/// with [respond]. PostgREST reads the request back from the response, so +/// it is attached here. +http.Client recordingHttpClient( + List requests, + http.Response Function(http.Request request) respond, +) => MockClient((request) async { + requests.add(request); + final response = respond(request); + return http.Response.bytes( + response.bodyBytes, + response.statusCode, + headers: response.headers, + request: request, + ); +}); + +http.Response jsonResponse(Object body, [int status = 200]) => http.Response( + jsonEncode(body), + status, + headers: {'content-type': 'application/json'}, +);