From 81a5feb980168e3662d536f8eafc113d893d21e5 Mon Sep 17 00:00:00 2001 From: Javi R <4920956+rameerez@users.noreply.github.com> Date: Tue, 2 Jun 2026 03:47:39 +0100 Subject: [PATCH 01/15] Lay v1 foundations: README/docs-first Trust & Safety rewrite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reframes moderate from a profanity validator (0.1) into a full Trust & Safety gem: report/block/filter/moderate + DSA / App Store / Play compliance. This is the docs-first pass — README, docs/, adaptive install migration, initializer, Gemfile/Appraisals/CI/SimpleCov coherence with the ecosystem. Gem code (models/services/concerns/filters) follows next. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/claude-code-review.yml | 42 ++ .github/workflows/claude.yml | 41 ++ .github/workflows/test.yml | 164 ++++++ .rubocop.yml | 8 + .simplecov | 37 ++ AGENTS.md | 7 + Appraisals | 16 + CLAUDE.md | 7 + Gemfile | 36 +- README.md | 371 +++++++++++-- Rakefile | 30 +- docs/compliance.md | 178 +++++++ docs/configuration.md | 296 +++++++++++ docs/dsa-notice-form.md | 347 +++++++++++++ docs/madmin.md | 490 ++++++++++++++++++ docs/notifications.md | 363 +++++++++++++ lib/generators/moderate/install_generator.rb | 56 ++ .../templates/create_moderate_tables.rb.erb | 274 ++++++++++ .../moderate/templates/initializer.rb | 153 ++++++ lib/moderate/version.rb | 2 +- moderate.gemspec | 19 +- test/test_helper.rb | 60 +++ 22 files changed, 2959 insertions(+), 38 deletions(-) create mode 100644 .github/workflows/claude-code-review.yml create mode 100644 .github/workflows/claude.yml create mode 100644 .github/workflows/test.yml create mode 100644 .rubocop.yml create mode 100644 .simplecov create mode 100644 AGENTS.md create mode 100644 Appraisals create mode 100644 CLAUDE.md create mode 100644 docs/compliance.md create mode 100644 docs/configuration.md create mode 100644 docs/dsa-notice-form.md create mode 100644 docs/madmin.md create mode 100644 docs/notifications.md create mode 100644 lib/generators/moderate/install_generator.rb create mode 100644 lib/generators/moderate/templates/create_moderate_tables.rb.erb create mode 100644 lib/generators/moderate/templates/initializer.rb create mode 100644 test/test_helper.rb diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml new file mode 100644 index 0000000..21964b5 --- /dev/null +++ b/.github/workflows/claude-code-review.yml @@ -0,0 +1,42 @@ +name: Claude Code Review + +on: + pull_request: + types: [opened, synchronize] + +jobs: + claude-review: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code Review + id: claude-review + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + prompt: | + REPO: ${{ github.repository }} + PR NUMBER: ${{ github.event.pull_request.number }} + + Please review this pull request and provide feedback on: + - Code quality and best practices + - Potential bugs or issues + - Performance considerations + - Security concerns + - Test coverage + + Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback. + + Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR. + + claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"' diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 0000000..6d46489 --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,41 @@ +name: Claude Code + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + pull_request_review: + types: [submitted] + +jobs: + claude: + if: | + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || + (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + actions: read # Required for Claude to read CI results on PRs + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code + id: claude + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + + # This is an optional setting that allows Claude to read CI results on PRs + additional_permissions: | + actions: read diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..84d6cfa --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,164 @@ +name: Tests + +on: + pull_request: + paths-ignore: + - "*.md" + - "LICENSE.txt" + push: + branches: + - main + paths-ignore: + - "*.md" + - "LICENSE.txt" + +jobs: + # Main test suite - tests Ruby versions and Rails compatibility with SQLite + sqlite: + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + ruby_version: ["3.3", "3.4", "4.0"] + gemfile: + - Gemfile + - gemfiles/rails_7.1.gemfile + - gemfiles/rails_7.2.gemfile + - gemfiles/rails_8.1.gemfile + + env: + RAILS_ENV: test + BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Ruby ${{ matrix.ruby_version }} + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby_version }} + bundler-cache: true + + - name: Prepare database and run tests + # Exercise the real migration path in SQLite too so dummy/test schema + # drift is caught in the default matrix, not only adapter-specific jobs. + run: bundle exec rake db:migrate:reset test + + - name: Upload test results + if: failure() + uses: actions/upload-artifact@v4 + with: + name: test-results-sqlite-ruby-${{ matrix.ruby_version }}-${{ matrix.gemfile }} + path: test/reports/ + retention-days: 7 + + # PostgreSQL compatibility tests + postgres: + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + ruby_version: ["3.4"] + + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: moderate_test + ports: + - 5432:5432 + options: >- + --health-cmd="pg_isready -U postgres" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + + env: + RAILS_ENV: test + DATABASE_URL: postgres://postgres:postgres@localhost:5432/moderate_test + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Ruby ${{ matrix.ruby_version }} + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby_version }} + bundler-cache: true + + - name: Prepare database + # Use db:migrate:reset instead of db:test:prepare to avoid loading schema.rb + # which has SQLite-specific defaults that fail on PostgreSQL. + # db:migrate:reset does: db:drop, db:create, db:migrate (using migrations, not schema.rb) + run: bundle exec rake db:migrate:reset + + - name: Run tests + run: bundle exec rake test + + - name: Upload test results + if: failure() + uses: actions/upload-artifact@v4 + with: + name: test-results-postgres-ruby-${{ matrix.ruby_version }} + path: test/reports/ + retention-days: 7 + + # MySQL compatibility tests + mysql: + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + ruby_version: ["3.4"] + + services: + mysql: + image: mysql:8 + env: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: moderate_test + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping -h localhost" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + + env: + RAILS_ENV: test + DATABASE_URL: mysql2://root:root@127.0.0.1:3306/moderate_test + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Ruby ${{ matrix.ruby_version }} + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby_version }} + bundler-cache: true + + - name: Prepare database + # Use db:migrate:reset instead of db:test:prepare to avoid loading schema.rb + # which has SQLite-specific defaults that fail on MySQL (JSON column defaults). + # db:migrate:reset does: db:drop, db:create, db:migrate (using migrations, not schema.rb) + run: bundle exec rake db:migrate:reset + + - name: Run tests + run: bundle exec rake test + + - name: Upload test results + if: failure() + uses: actions/upload-artifact@v4 + with: + name: test-results-mysql-ruby-${{ matrix.ruby_version }} + path: test/reports/ + retention-days: 7 diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..ae378d0 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,8 @@ +AllCops: + TargetRubyVersion: 3.2 + +Style/StringLiterals: + EnforcedStyle: double_quotes + +Style/StringLiteralsInInterpolation: + EnforcedStyle: double_quotes diff --git a/.simplecov b/.simplecov new file mode 100644 index 0000000..3f3ec4b --- /dev/null +++ b/.simplecov @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +# SimpleCov configuration file (auto-loaded before test suite) +# This keeps test_helper.rb clean and follows best practices. +# Coherent with the rest of the gem ecosystem (usage_credits, pricing_plans, …). + +SimpleCov.start do + # Use SimpleFormatter for terminal-only output (no HTML generation) + formatter SimpleCov::Formatter::SimpleFormatter + + # Don't count the test suite itself toward coverage + add_filter "/test/" + + # Track Ruby files in the lib directory (gem source code) + track_files "lib/**/*.rb" + + # Enable branch coverage for more detailed metrics + enable_coverage :branch + + # Minimum coverage thresholds to prevent coverage regression + minimum_coverage line: 80, branch: 75 + + # Disambiguate parallel test runs + command_name "Job #{ENV['TEST_ENV_NUMBER']}" if ENV["TEST_ENV_NUMBER"] +end + +# Print coverage summary to terminal after tests complete +SimpleCov.at_exit do + SimpleCov.result.format! + puts "\n" + "=" * 60 + puts "COVERAGE SUMMARY" + puts "=" * 60 + puts "Line Coverage: #{SimpleCov.result.covered_percent.round(2)}%" + branch_coverage = SimpleCov.result.coverage_statistics[:branch]&.percent&.round(2) || "N/A" + puts "Branch Coverage: #{branch_coverage}%" + puts "=" * 60 +end diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..472fb8a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,7 @@ +# AGENTS.md + +This file provides guidance to AI Agents (like OpenAI's Codex, Cursor Agent, Claude Code, etc) when working with code in this repository. + +Please read the `README.md` for a full overview of the gem's API and philosophy, and the `docs/` directory (`docs/configuration.md`, `docs/notifications.md`, `docs/compliance.md`, `docs/madmin.md`, `docs/dsa-notice-form.md`) for the detailed integration guides. + +This gem is part of a coherent ecosystem (`railsfast`, `goodmail`, `telegrama`, `usage_credits`, `pricing_plans`, `wallets`, `api_keys`). Match the ecosystem conventions exactly: a single `Moderate.configure do |config| … end` block, `has_*`/verb-style class macros, adapter objects + no-op-default hook procs, string class names constantized lazily, adaptive install migrations, Minitest with a `test/dummy` app, SimpleCov, and the README/docs voice. diff --git a/Appraisals b/Appraisals new file mode 100644 index 0000000..392c3b2 --- /dev/null +++ b/Appraisals @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +# Test the minimum supported Rails version (matches the gemspec floor and the +# README's "Rails 7.1+ schema" claim — the adaptive migration must work here). +appraise "rails-7.1" do + gem "rails", "~> 7.1.0" +end + +appraise "rails-7.2" do + gem "rails", "~> 7.2.0" +end + +# Test the latest Rails version — this is the default/main Gemfile anyway. +appraise "rails-8.1" do + gem "rails", "~> 8.1.0" +end diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6e9a261 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,7 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +Please read the `README.md` for a full overview of the gem's API and philosophy, and the `docs/` directory (`docs/configuration.md`, `docs/notifications.md`, `docs/compliance.md`, `docs/madmin.md`, `docs/dsa-notice-form.md`) for the detailed integration guides. + +This gem is part of a coherent ecosystem (`railsfast`, `goodmail`, `telegrama`, `usage_credits`, `pricing_plans`, `wallets`, `api_keys`). Match the ecosystem conventions exactly: a single `Moderate.configure do |config| … end` block, `has_*`/verb-style class macros, adapter objects + no-op-default hook procs, string class names constantized lazily, adaptive install migrations, Minitest with a `test/dummy` app, SimpleCov, and the README/docs voice. diff --git a/Gemfile b/Gemfile index 1d2662c..f6df1d6 100644 --- a/Gemfile +++ b/Gemfile @@ -2,7 +2,41 @@ source "https://rubygems.org" -# Specify your gem's dependencies in moderate.gemspec +# Runtime dependencies are specified in moderate.gemspec gemspec +# Build & release tools gem "rake", "~> 13.0" + +group :development do + gem "appraisal" + gem "web-console" + + # Code quality + gem "standard" + gem "rubocop", "~> 1.0" + gem "rubocop-minitest", "~> 0.35" + gem "rubocop-performance", "~> 1.0" +end + +group :test do + gem "minitest", "~> 5.0" + gem "mocha" + gem "simplecov", require: false + + # Database adapters (for multi-database testing) + gem "sqlite3" + gem "pg" + gem "mysql2" + + # Dummy Rails app + gem "bootsnap", require: false + gem "puma" + gem "importmap-rails" + gem "sprockets-rails" + gem "stimulus-rails" + gem "turbo-rails" + + # Fix RDoc version conflict (Ruby 3.4+ ships with 7.0.3) + gem "rdoc", ">= 7.0" +end diff --git a/README.md b/README.md index 71f437a..58f3d2b 100644 --- a/README.md +++ b/README.md @@ -1,71 +1,384 @@ -# 👮‍♂️ `moderate` - Block profanities in text fields +# 🛡️ `moderate` - Trust & Safety for your Rails app (report, block, filter, comply) -`moderate` is a Ruby gem that moderates user-generated text content by adding a simple validation to block bad words in any text field (profanities, cussing, swearing, obscenity, etc.) +[![Gem Version](https://badge.fury.io/rb/moderate.svg)](https://badge.fury.io/rb/moderate) [![Build Status](https://github.com/rameerez/moderate/workflows/Tests/badge.svg)](https://github.com/rameerez/moderate/actions) -Simply add this to your model: +> [!TIP] +> **🚀 Ship your next Rails app 10x faster!** I've built **[RailsFast](https://railsfast.com/?ref=moderate)**, a production-ready Rails boilerplate template that comes with everything you need to launch a software business in days, not weeks. Go [check it out](https://railsfast.com/?ref=moderate)! + +`moderate` gives your Rails app a complete **Trust & Safety** layer — let users **report** abusive content, **block** each other, **filter** objectionable text and images before they're posted, and run a **moderation queue** your admins actually use. It ships **DSA-compliant** (EU Digital Services Act) and aligned with the **Apple App Store** and **Google Play** review guidelines for user-generated content, so you stop leaving store approvals and legal exposure to chance. + +It reads like plain English. Make any model reportable: + +```ruby +class Comment < ApplicationRecord + reportable +end +``` + +Let any user block another: ```ruby -validates :text_field, moderate: true +current_user.block!(@other_user) +current_user.blocks?(@other_user) # => true ``` -That's it! You're done. `moderate` will work seamlessly with your existing validations and error messages. +Filter content before it's ever saved — one line: + +```ruby +class Message < ApplicationRecord + moderates :body # blocks profanity/slurs/threats by default; or :flag for review +end +``` + +And give admins a real queue to act on: + +```ruby +Moderate::Report.pending # everything awaiting a decision +report.resolve!(by: current_user, remove_content: true, ban_user: true, note: "Hate speech") +``` + +That's the whole idea: **the messy, legally-loaded plumbing every social/UGC app needs — report, block, filter, moderate, appeal, comply — as one coherent, Ruby-esque gem** instead of scattered, half-finished, store-rejecting DIY code. + +> [!NOTE] +> `moderate` is **UI-agnostic by design**: most Trust & Safety lives in *admin* surfaces, so the gem ships the **primitives** (models, services, helpers, controller concerns) and lets you **bring your own UI**. It plugs into [`madmin`](https://github.com/excid3/madmin) (or any admin) in minutes — see [Admin & moderation queue](#-admin--the-moderation-queue). It also snaps into the rest of the ecosystem: [`goodmail`](https://github.com/rameerez/goodmail) for decision emails, [`telegrama`](https://github.com/rameerez/telegrama) for admin alerts, and [`noticed`](https://github.com/excid3/noticed) for multi-channel notifications — all through one `notify` hook. + +--- + +## Why this gem exists -> [!WARNING] -> This gem is under development. It currently only supports a limited set of English profanity words. Word matching is very basic now, and it may be prone to false positives, and false negatives. I use it for very simple things like preventing new submissions if they contain bad words, but the gem can be improved for more complex use cases and sophisticated matching and content moderation. Please consider contributing if you can improve the gem, or have good ideas for additional features. +Every app with user-generated content eventually faces the same wall. A user posts something vile, another user wants them gone, Apple rejects your build for "no way to report objectionable content," and a Spanish lawyer emails you about the Digital Services Act. So you start bolting on a `reports` table, a `blocks` table, a profanity regex, an admin page, a "notify the reporter" email… and it's suddenly a sprawling, half-correct subsystem entangled with your core app. -# Why +It's the kind of plumbing nobody wants to build, everybody rebuilds, and almost everybody ships *incomplete* — which is exactly what gets apps rejected from the stores and exposed under the DSA. `moderate` is the single, opinionated, batteries-included source of truth for it: -Any text field where users can input text may be a place where bad words can be used. This gem blocks records from being created if they contain bad words, profanity, naughty / obscene words, etc. +- **Report** users and content (in-app), with evidence snapshots and a real decision workflow. +- **Block** users (bidirectional), enforced everywhere a blocked pair could reconnect. +- **Filter** text and images before they're posted (`:off` / `:block` / `:flag`), with pluggable backends — a built-in offline wordlist and OpenAI's free multimodal moderation, or your own. +- **Moderate** from a queue: remove content, ban users, dismiss, all audited. +- **Comply**: DSA notice-and-action (Art. 16), statement of reasons (Art. 17), internal appeals (Art. 20), transparency counters (Art. 24); Apple Guideline 1.2 and Google Play UGC requirements. -It's good for Rails applications where you need to maintain a clean and respectful environment in comments, posts, or any other user input. +It works standalone, and gets better with the rest of the ecosystem. -# How +## What `moderate` does and doesn't do -`moderate` currently downloads a list of ~1k English profanity words from the [google-profanity-words](https://github.com/coffee-and-fun/google-profanity-words) repository and caches it in your Rails app's tmp directory. +**Does:** +- User & content **reporting** (in-app) + a public **DSA legal-notice** intake form. +- **Blocking** with a single source-of-truth query you enforce in search, messaging, profiles, anywhere. +- **Pre-publication content filtering** with three modes and pluggable adapters (text, image, LLM). +- A **moderation queue** with audited resolve / dismiss / remove-content / ban actions. +- **Appeals**, **statement-of-reasons** notifications, and **transparency** aggregation for the DSA. +- Optional **audit** and **notification** hooks that fan out to your mailer / admin alerts / push. -## Installation +**Doesn't** (on purpose — these are other tools' jobs): +- Authentication / current-user (that's Devise — you tell `moderate` your user class). +- Sending the actual emails/push (that's [`goodmail`](https://github.com/rameerez/goodmail) / [`noticed`](https://github.com/excid3/noticed) — `moderate` just emits events). +- The admin UI chrome (that's [`madmin`](https://github.com/excid3/madmin) / your app — `moderate` gives you the data + primitives). +- A bulletproof ML classifier out of the box (the default text filter is a fast, multilingual wordlist; bring an LLM/image adapter when you want one). -Add this line to your application's Gemfile: +--- + +## Quickstart + +Add the gem: ```ruby -gem 'moderate' +gem "moderate" ``` -And then execute: +Install it (creates the migration + an initializer): ```bash bundle install +rails generate moderate:install +rails db:migrate ``` -Then, just add the `moderate` validation to any model with a text field: +Tell `moderate` who your users are, and make a model reportable: ```ruby -validates :text_field, moderate: true +# config/initializers/moderate.rb +Moderate.configure do |config| + config.user_class = "User" +end +``` + +```ruby +class User < ApplicationRecord + has_moderation # can report, block, be blocked, be banned +end + +class Message < ApplicationRecord + reportable # can be reported + moderates :body # …and filtered before it's saved +end ``` -`moderate` will raise an error if a bad word is found in the text field, preventing the record from being saved. +That's it — you now have reporting, blocking, filtering, and a moderation queue. Everything below is detail. + +--- -It works seamlessly with your existing validations and error messages. +## 🧑‍🤝‍🧑 Actors: report & block -## Configuration +Add `has_moderation` to your user model (or any model that acts on behalf of a person) — it sits right alongside your other ecosystem macros like `has_credits` / `has_wallets`: + +```ruby +class User < ApplicationRecord + has_moderation +end +``` + +*(Prefer an explicit include? `include Moderate::Actor` is the exact equivalent — the macro just lazily includes it.)* + +**Blocking** is a bidirectional safety edge — once either side blocks, neither should see or reach the other: + +```ruby +current_user.block!(@other) # idempotent; audited; fires your on_block hook +current_user.unblock!(@other) +current_user.blocks?(@other) # I blocked them +current_user.blocked_by?(@other) +current_user.blocked_with?(@other) # either direction — the one you check in features +``` + +Enforce it anywhere with the single source-of-truth query (no hand-rolled block SQL ever again): + +```ruby +# Hide blocked people from a marketplace / search / inbox: +Post.where.not(user_id: Moderate.blocked_ids_for(current_user)) +``` + +**Reporting** content or a person: + +```ruby +current_user.report!(@message, category: :harassment, details: "Won't stop messaging me") +current_user.report!(@user, category: :impersonation) +``` + +`moderate` snapshots the offending content at report time (so evidence survives edits/deletes), infers who's responsible, sends the reporter a receipt, and drops it in the queue. + +## 🚩 Reportable content + +Declare what can be reported with one `reportable` line — the fields are optional (omit them to report the whole record): + +```ruby +class Listing < ApplicationRecord + reportable :title, :description + + # Tell moderate how to present & clean this content when a moderator acts: + def moderation_label = "Listing #{id}" + def reported_owner = user # who's responsible (defaults sensibly) +end +``` + +*(Explicit-include equivalent: `include Moderate::Reportable` + `reportable_fields :title, :description`.)* + +You get: + +```ruby +listing.reports # reports filed against this record +listing.reported? # any open report? +listing.flagged? # any pending system (auto-filter) flag? +``` + +Drop a report link into any view with the helper (it renders nothing if the viewer can't report the content): + +```erb +<%= moderate_report_link(@listing, field: :description) %> +``` + +Adding a new reportable type is one `reportable` line — the intake, queue, snapshot, and admin code never change. + +## 🧪 Content filtering: `:off` / `:block` / `:flag` + +Filtering is one declaration per field, with three modes: + +```ruby +class Message < ApplicationRecord + moderates :body # uses the default mode (see config) +end + +class Profile < ApplicationRecord + moderates :bio, mode: :block # reject the save if it trips the filter + moderates :avatar, mode: :flag, with: :image # allow the save, queue it for review +end +``` + +- **`:off`** — no check. +- **`:block`** — the write is rejected with a validation error (great for public, high-trust fields). +- **`:flag`** — the write **succeeds**, and a `Moderate::Flag` is created **after commit** for human or automated review (great for DMs, where you don't want to block mid-conversation). + +Why this matters: `:flag` never lives in a validator (validators must be side-effect-free, and a flag created inside a rolled-back transaction would silently vanish) — `moderate` handles that correctly for you. + +Check content directly anywhere: + +```ruby +result = Moderate.classify("some sketchy text") +result.allowed? # => false +result.categories # => [:hate, :"hate/threatening"] +result.scores # => { "hate" => 0.97, "hate/threatening" => 0.81 } (0..1 for service adapters) +result.labels # => [#