moderate 0.1.0 → 1.0.0.beta1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop.yml +8 -0
- data/.simplecov +62 -0
- data/AGENTS.md +7 -0
- data/Appraisals +16 -0
- data/CHANGELOG.md +71 -1
- data/CLAUDE.md +7 -0
- data/README.md +376 -29
- data/Rakefile +28 -2
- data/app/controllers/concerns/moderate/moderation.rb +161 -0
- data/app/controllers/moderate/appeals_controller.rb +190 -0
- data/app/controllers/moderate/application_controller.rb +45 -0
- data/app/controllers/moderate/notices_controller.rb +382 -0
- data/app/controllers/moderate/transparency_reports_controller.rb +30 -0
- data/app/helpers/moderate/engine_helper.rb +151 -0
- data/app/views/moderate/appeals/new.html.erb +78 -0
- data/app/views/moderate/notices/new.html.erb +255 -0
- data/app/views/moderate/transparency_reports/_summary_card.html.erb +20 -0
- data/app/views/moderate/transparency_reports/show.html.erb +52 -0
- data/config/moderate/blocklists/en.yml +81 -0
- data/config/moderate/blocklists/es.yml +40 -0
- data/config/routes.rb +36 -0
- data/docs/compliance.md +178 -0
- data/docs/configuration.md +326 -0
- data/docs/dsa-notice-form.md +371 -0
- data/docs/madmin.md +490 -0
- data/docs/notifications.md +363 -0
- data/examples/aws_rekognition_adapter.rb +140 -0
- data/examples/openai_moderation_adapter.rb +111 -0
- data/gemfiles/rails_7.1.gemfile +36 -0
- data/gemfiles/rails_7.2.gemfile +36 -0
- data/gemfiles/rails_8.1.gemfile +36 -0
- data/lib/generators/moderate/install_generator.rb +56 -0
- data/lib/generators/moderate/templates/create_moderate_tables.rb.erb +237 -0
- data/lib/generators/moderate/templates/initializer.rb +198 -0
- data/lib/generators/moderate/views_generator.rb +63 -0
- data/lib/moderate/configuration.rb +341 -0
- data/lib/moderate/engine.rb +138 -0
- data/lib/moderate/errors.rb +26 -0
- data/lib/moderate/event.rb +75 -0
- data/lib/moderate/filters/base.rb +126 -0
- data/lib/moderate/filters/wordlist.rb +255 -0
- data/lib/moderate/jobs/classify_job.rb +158 -0
- data/lib/moderate/label.rb +111 -0
- data/lib/moderate/macros.rb +90 -0
- data/lib/moderate/models/appeal.rb +154 -0
- data/lib/moderate/models/application_record.rb +31 -0
- data/lib/moderate/models/block.rb +203 -0
- data/lib/moderate/models/concerns/actor.rb +174 -0
- data/lib/moderate/models/concerns/content_filterable.rb +155 -0
- data/lib/moderate/models/concerns/reportable.rb +282 -0
- data/lib/moderate/models/flag.rb +136 -0
- data/lib/moderate/models/report.rb +620 -0
- data/lib/moderate/result.rb +176 -0
- data/lib/moderate/services/intake_appeal.rb +89 -0
- data/lib/moderate/services/intake_notice.rb +132 -0
- data/lib/moderate/services/intake_report.rb +132 -0
- data/lib/moderate/services/resolve_appeal.rb +134 -0
- data/lib/moderate/services/resolve_flag.rb +101 -0
- data/lib/moderate/services/resolve_report.rb +291 -0
- data/lib/moderate/version.rb +1 -1
- data/lib/moderate.rb +365 -18
- data/log/development.log +0 -0
- data/log/test.log +0 -0
- metadata +154 -15
data/docs/madmin.md
ADDED
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
# Admin & the moderation queue — wiring `moderate` into `madmin` (BYOUI)
|
|
2
|
+
|
|
3
|
+
Most of Trust & Safety lives in *admin*: a human (or a script) looking at a queue of reports, flags, and appeals and making a call. `moderate` deliberately does **not** ship admin chrome — it ships the **primitives** (the `Moderate::Report` / `Moderate::Flag` / `Moderate::Appeal` / `Moderate::Block` models, the `resolve!` / `dismiss!` / `uphold!` / `reject!` decision methods, the queue scopes, the controller concern), and lets you **bring your own UI**.
|
|
4
|
+
|
|
5
|
+
This guide is the full walkthrough for the most common BYOUI choice: [`madmin`](https://github.com/excid3/madmin). It's the exact recipe a real production app uses, distilled. If you're on ActiveAdmin, Avo, Trestle, or a hand-rolled admin, the *shape* is identical — generate a screen against the model, add two buttons, POST to a custom action that calls the gem's decision method. Only the framework glue changes.
|
|
6
|
+
|
|
7
|
+
> [!NOTE]
|
|
8
|
+
> Why no built-in admin? Trust & Safety UI is the part every app wants to own — your branding, your auth, your layout, your extra columns. The decision *logic* (atomic content removal, ban, notify, audit) is the part nobody should reimplement. `moderate` draws the line exactly there: the gem owns the `resolve!`/`dismiss!`/`uphold!`/`reject!` transaction; you own the screen that calls it. See [What `moderate` does and doesn't do](../README.md#what-moderate-does-and-doesnt-do).
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## TL;DR
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
# 1. Generate one madmin resource per moderation model
|
|
16
|
+
rails generate madmin:resource Moderate::Report
|
|
17
|
+
rails generate madmin:resource Moderate::Flag
|
|
18
|
+
rails generate madmin:resource Moderate::Appeal
|
|
19
|
+
rails generate madmin:resource Moderate::Block
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
# 2. Add member routes for the decisions (config/routes.rb, inside your madmin namespace)
|
|
24
|
+
resources :reports, only: [:index, :show] do
|
|
25
|
+
member { post :resolve; post :dismiss }
|
|
26
|
+
end
|
|
27
|
+
resources :flags, only: [:index, :show] do
|
|
28
|
+
member { post :resolve; post :dismiss }
|
|
29
|
+
end
|
|
30
|
+
resources :appeals, only: [:index, :show] do
|
|
31
|
+
member { post :uphold; post :reject }
|
|
32
|
+
end
|
|
33
|
+
resources :blocks, only: [:index, :show]
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
# 3. Subclass the madmin resource controller and call the gem's decision methods
|
|
38
|
+
module Madmin
|
|
39
|
+
class ReportsController < Madmin::ResourceController
|
|
40
|
+
def resolve
|
|
41
|
+
@record.resolve!(by: current_user, remove_content: params[:remove_content],
|
|
42
|
+
ban_user: params[:ban_user], note: params[:note])
|
|
43
|
+
redirect_to main_app.madmin_report_path(@record), notice: "Report resolved.", status: :see_other
|
|
44
|
+
rescue => e
|
|
45
|
+
redirect_to main_app.madmin_report_path(@record), alert: "Could not resolve: #{e.message}", status: :see_other
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def dismiss
|
|
49
|
+
@record.dismiss!(by: current_user, note: params[:note])
|
|
50
|
+
redirect_to main_app.madmin_report_path(@record), notice: "Report dismissed.", status: :see_other
|
|
51
|
+
rescue => e
|
|
52
|
+
redirect_to main_app.madmin_report_path(@record), alert: "Could not dismiss: #{e.message}", status: :see_other
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
That's the whole pattern. The rest of this doc fills in the resource definitions, the queue, the decision buttons, and the gotchas.
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## The model primitives `madmin` points at
|
|
63
|
+
|
|
64
|
+
`moderate`'s models are **plain ActiveRecord**, so they show up in `madmin` like any other model — no special integration. Here's what you're admining and the queue scope on each:
|
|
65
|
+
|
|
66
|
+
| Model | What it is | The queue scope | Decide with |
|
|
67
|
+
| --- | --- | --- | --- |
|
|
68
|
+
| `Moderate::Report` | In-app reports **and** public DSA notices (one table, distinguished by `intake_kind`) | `Moderate::Report.pending` | `report.resolve!` / `report.dismiss!` |
|
|
69
|
+
| `Moderate::Flag` | Auto-filter flags from `:flag`-mode `moderates` (source: `text_filter` / `image_filter` / `external_classifier` / `manual`) | `Moderate::Flag.pending` | `flag.resolve!` / `flag.dismiss!` |
|
|
70
|
+
| `Moderate::Appeal` | DSA Art. 20 internal complaints against a decision | `Moderate::Appeal.pending` | `appeal.uphold!` / `appeal.reject!` |
|
|
71
|
+
| `Moderate::Block` | The bidirectional `blocker`/`blocked` safety edge | (no decision — read-only) | n/a |
|
|
72
|
+
|
|
73
|
+
The same `pending` scope is what a **human admin** reads in `madmin` *and* what an **automated ML consumer** reads in a background job — one queue, two readers. (More on that in [Automated review](#automated-review-the-same-pending-queue).)
|
|
74
|
+
|
|
75
|
+
> [!IMPORTANT]
|
|
76
|
+
> Decisions are **only** ever made by calling the gem's methods (`resolve!`, `dismiss!`, `uphold!`, `reject!`). Don't let madmin's stock edit form mutate `status` directly. Every decision is atomic, requires a moderator + a note, runs your enforcement (content removal via the reportable's own `remove_reported_field!`, bans via your `ban_handler`), fires the `notify` / `audit` hooks, and stamps the appeal window. A raw `status = "resolved"` update skips the audited decision workflow. Keep the models **read-only** in madmin (`form: false`) and route every change through a custom member action — exactly what this guide does.
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## Step 1 — Generate the resources
|
|
81
|
+
|
|
82
|
+
`madmin`'s generator works against namespaced models out of the box:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
rails generate madmin:resource Moderate::Report
|
|
86
|
+
rails generate madmin:resource Moderate::Flag
|
|
87
|
+
rails generate madmin:resource Moderate::Appeal
|
|
88
|
+
rails generate madmin:resource Moderate::Block
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Each creates `app/madmin/resources/moderate/<model>_resource.rb` and a flat controller stub. Now edit the resources to make them a real moderation queue — read-only columns, queue scopes, and a useful index order.
|
|
92
|
+
|
|
93
|
+
### `Moderate::ReportResource`
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
# app/madmin/resources/moderate/report_resource.rb
|
|
97
|
+
class Moderate::ReportResource < Madmin::Resource
|
|
98
|
+
model Moderate::Report
|
|
99
|
+
|
|
100
|
+
# Everything is read-only (form: false): decisions go through the custom
|
|
101
|
+
# member actions below, never through madmin's stock edit form.
|
|
102
|
+
attribute :id, index: true, form: false
|
|
103
|
+
attribute :status, index: true, form: false # pending / resolved / dismissed
|
|
104
|
+
attribute :kind, index: true, form: false # "report" (in-app) or "dsa_notice"
|
|
105
|
+
attribute :category, index: true, form: false # community category OR DSA legal_reason
|
|
106
|
+
attribute :reportable, :polymorphic, index: true, form: false, label: "Target"
|
|
107
|
+
attribute :reported_field, index: true, form: false, label: "Field"
|
|
108
|
+
attribute :reported_user, index: true, form: false
|
|
109
|
+
attribute :reporter, index: true, form: false
|
|
110
|
+
attribute :notifier_email, index: true, form: false, label: "Notifier" # DSA notices
|
|
111
|
+
attribute :details, index: false, form: false
|
|
112
|
+
attribute :snapshot, index: false, form: false # the immutable evidence snapshot
|
|
113
|
+
attribute :resolution_note, index: false, form: false
|
|
114
|
+
attribute :created_at, index: true, form: false, label: "Received"
|
|
115
|
+
attribute :resolved_at, index: true, form: false
|
|
116
|
+
|
|
117
|
+
# Queue scopes — these are the gem's own scopes, surfaced as madmin filters.
|
|
118
|
+
scope :pending
|
|
119
|
+
scope :resolved
|
|
120
|
+
scope :dismissed
|
|
121
|
+
|
|
122
|
+
menu label: "Reports", parent: "Trust & Safety"
|
|
123
|
+
|
|
124
|
+
def self.display_name(record) = "Report ##{record.id.to_s.first(8)}"
|
|
125
|
+
def self.default_sort_column = "created_at"
|
|
126
|
+
def self.default_sort_direction = "desc"
|
|
127
|
+
end
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### `Moderate::FlagResource`
|
|
131
|
+
|
|
132
|
+
```ruby
|
|
133
|
+
# app/madmin/resources/moderate/flag_resource.rb
|
|
134
|
+
class Moderate::FlagResource < Madmin::Resource
|
|
135
|
+
model Moderate::Flag
|
|
136
|
+
|
|
137
|
+
attribute :id, index: true, form: false
|
|
138
|
+
attribute :status, index: true, form: false # pending / resolved / dismissed
|
|
139
|
+
attribute :source, index: true, form: false # wordlist / image / <your adapter> / manual
|
|
140
|
+
attribute :flaggable, :polymorphic, index: true, form: false, label: "Target"
|
|
141
|
+
attribute :field, index: true, form: false
|
|
142
|
+
attribute :owner, index: true, form: false
|
|
143
|
+
attribute :categories, index: false, form: false # e.g. [:hate, :threats]
|
|
144
|
+
attribute :scores, index: false, form: false # { hate: 1.0 } (0..1 for ML adapters)
|
|
145
|
+
attribute :resolution_note, index: false, form: false
|
|
146
|
+
attribute :created_at, index: true, form: false
|
|
147
|
+
attribute :reviewed_at, index: true, form: false
|
|
148
|
+
|
|
149
|
+
scope :pending
|
|
150
|
+
scope :resolved
|
|
151
|
+
scope :dismissed
|
|
152
|
+
|
|
153
|
+
menu label: "Flags", parent: "Trust & Safety"
|
|
154
|
+
|
|
155
|
+
def self.display_name(record) = "Flag ##{record.id.to_s.first(8)}"
|
|
156
|
+
def self.default_sort_column = "created_at"
|
|
157
|
+
def self.default_sort_direction = "desc"
|
|
158
|
+
end
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### `Moderate::AppealResource`
|
|
162
|
+
|
|
163
|
+
```ruby
|
|
164
|
+
# app/madmin/resources/moderate/appeal_resource.rb
|
|
165
|
+
class Moderate::AppealResource < Madmin::Resource
|
|
166
|
+
model Moderate::Appeal
|
|
167
|
+
|
|
168
|
+
attribute :id, index: true, form: false
|
|
169
|
+
attribute :status, index: true, form: false # pending / upheld / rejected
|
|
170
|
+
attribute :report, index: true, form: false # the decision being appealed
|
|
171
|
+
attribute :appellant_email, index: true, form: false
|
|
172
|
+
attribute :reason, index: false, form: false
|
|
173
|
+
attribute :resolution_note, index: false, form: false
|
|
174
|
+
attribute :created_at, index: true, form: false, label: "Received"
|
|
175
|
+
attribute :resolved_at, index: true, form: false
|
|
176
|
+
|
|
177
|
+
scope :pending
|
|
178
|
+
scope :upheld
|
|
179
|
+
scope :rejected
|
|
180
|
+
|
|
181
|
+
menu label: "Appeals", parent: "Trust & Safety"
|
|
182
|
+
|
|
183
|
+
def self.display_name(record) = "Appeal ##{record.id.to_s.first(8)}"
|
|
184
|
+
def self.default_sort_column = "created_at"
|
|
185
|
+
def self.default_sort_direction = "desc"
|
|
186
|
+
end
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
### `Moderate::BlockResource` (read-only)
|
|
190
|
+
|
|
191
|
+
Blocks have no decision — they're a user safety edge, so they're a plain read-only list for support visibility:
|
|
192
|
+
|
|
193
|
+
```ruby
|
|
194
|
+
# app/madmin/resources/moderate/block_resource.rb
|
|
195
|
+
class Moderate::BlockResource < Madmin::Resource
|
|
196
|
+
model Moderate::Block
|
|
197
|
+
|
|
198
|
+
attribute :id, index: true, form: false
|
|
199
|
+
attribute :blocker, index: true, form: false
|
|
200
|
+
attribute :blocked, index: true, form: false
|
|
201
|
+
attribute :created_at, index: true, form: false
|
|
202
|
+
|
|
203
|
+
menu label: "Blocks", parent: "Trust & Safety"
|
|
204
|
+
|
|
205
|
+
def self.display_name(record) = "Block ##{record.id.to_s.first(8)}"
|
|
206
|
+
end
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
> [!TIP]
|
|
210
|
+
> Group all four under one `parent:` (here `"Trust & Safety"`) so they sit together in the madmin sidebar — that grouping *is* your moderation queue's navigation.
|
|
211
|
+
|
|
212
|
+
---
|
|
213
|
+
|
|
214
|
+
## Step 2 — Routes for the decisions
|
|
215
|
+
|
|
216
|
+
`madmin` gives you `index`/`show` for free. The decisions are custom **member** actions you add yourself, the standard Rails way. Inside your madmin namespace:
|
|
217
|
+
|
|
218
|
+
```ruby
|
|
219
|
+
# config/routes.rb
|
|
220
|
+
namespace :madmin do
|
|
221
|
+
resources :reports, only: [:index, :show] do
|
|
222
|
+
member do
|
|
223
|
+
post :resolve
|
|
224
|
+
post :dismiss
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
resources :flags, only: [:index, :show] do
|
|
229
|
+
member do
|
|
230
|
+
post :resolve
|
|
231
|
+
post :dismiss
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
resources :appeals, only: [:index, :show] do
|
|
236
|
+
member do
|
|
237
|
+
post :uphold
|
|
238
|
+
post :reject
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
resources :blocks, only: [:index, :show] # read-only
|
|
243
|
+
end
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
This gives you `resolve_madmin_report_path(report)`, `dismiss_madmin_report_path(report)`, `uphold_madmin_appeal_path(appeal)`, and friends — the URLs your decision buttons POST to.
|
|
247
|
+
|
|
248
|
+
> [!NOTE]
|
|
249
|
+
> Keep `only: [:index, :show]`. There's no `:edit`/`:update`/`:destroy` because **the only legitimate way to change a moderation record is a decision method**, and those live behind the member actions, not the stock REST update.
|
|
250
|
+
|
|
251
|
+
---
|
|
252
|
+
|
|
253
|
+
## Step 3 — The controller (call the gem's decision methods)
|
|
254
|
+
|
|
255
|
+
This is the heart of the integration, and it's tiny. Subclass `Madmin::ResourceController`, add one action per decision, and have each action call the matching `moderate` method. `@record` is set for you by `madmin` (it's the report/flag/appeal the member route resolved).
|
|
256
|
+
|
|
257
|
+
```ruby
|
|
258
|
+
# app/controllers/madmin/reports_controller.rb
|
|
259
|
+
module Madmin
|
|
260
|
+
class ReportsController < Madmin::ResourceController
|
|
261
|
+
def resolve
|
|
262
|
+
@record.resolve!(
|
|
263
|
+
by: current_user, # the moderator (required)
|
|
264
|
+
remove_content: params[:remove_content], # runs reportable#remove_reported_field!
|
|
265
|
+
ban_user: params[:ban_user], # runs your config.ban_handler
|
|
266
|
+
note: params[:note] # required — the decision rationale
|
|
267
|
+
)
|
|
268
|
+
redirect_to main_app.madmin_report_path(@record),
|
|
269
|
+
notice: "Report resolved.", status: :see_other
|
|
270
|
+
rescue => error
|
|
271
|
+
redirect_to main_app.madmin_report_path(@record),
|
|
272
|
+
alert: "Could not resolve report: #{error.message}", status: :see_other
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def dismiss
|
|
276
|
+
@record.dismiss!(by: current_user, note: params[:note])
|
|
277
|
+
redirect_to main_app.madmin_report_path(@record),
|
|
278
|
+
notice: "Report dismissed.", status: :see_other
|
|
279
|
+
rescue => error
|
|
280
|
+
redirect_to main_app.madmin_report_path(@record),
|
|
281
|
+
alert: "Could not dismiss report: #{error.message}", status: :see_other
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
private
|
|
285
|
+
|
|
286
|
+
# Eager-load the associations the index/show touch, so the queue page
|
|
287
|
+
# doesn't N+1 across reporter / reported_user / target.
|
|
288
|
+
def scoped_resources
|
|
289
|
+
super.includes(:reporter, :reported_user, :reportable)
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
```ruby
|
|
296
|
+
# app/controllers/madmin/flags_controller.rb
|
|
297
|
+
module Madmin
|
|
298
|
+
class FlagsController < Madmin::ResourceController
|
|
299
|
+
def resolve
|
|
300
|
+
@record.resolve!(by: current_user, note: params[:note])
|
|
301
|
+
redirect_to main_app.madmin_flag_path(@record), notice: "Flag actioned.", status: :see_other
|
|
302
|
+
rescue => error
|
|
303
|
+
redirect_to main_app.madmin_flag_path(@record), alert: "Could not action flag: #{error.message}", status: :see_other
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def dismiss
|
|
307
|
+
@record.dismiss!(by: current_user, note: params[:note])
|
|
308
|
+
redirect_to main_app.madmin_flag_path(@record), notice: "Flag dismissed.", status: :see_other
|
|
309
|
+
rescue => error
|
|
310
|
+
redirect_to main_app.madmin_flag_path(@record), alert: "Could not dismiss flag: #{error.message}", status: :see_other
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
private
|
|
314
|
+
|
|
315
|
+
def scoped_resources
|
|
316
|
+
super.includes(:flaggable, :owner)
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
```ruby
|
|
323
|
+
# app/controllers/madmin/appeals_controller.rb
|
|
324
|
+
module Madmin
|
|
325
|
+
class AppealsController < Madmin::ResourceController
|
|
326
|
+
def uphold
|
|
327
|
+
@record.uphold!(by: current_user, note: params[:note]) # overturns the original decision
|
|
328
|
+
redirect_to main_app.madmin_appeal_path(@record), notice: "Appeal upheld.", status: :see_other
|
|
329
|
+
rescue => error
|
|
330
|
+
redirect_to main_app.madmin_appeal_path(@record), alert: "Could not uphold appeal: #{error.message}", status: :see_other
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def reject
|
|
334
|
+
@record.reject!(by: current_user, note: params[:note]) # confirms the original decision
|
|
335
|
+
redirect_to main_app.madmin_appeal_path(@record), notice: "Appeal rejected.", status: :see_other
|
|
336
|
+
rescue => error
|
|
337
|
+
redirect_to main_app.madmin_appeal_path(@record), alert: "Could not reject appeal: #{error.message}", status: :see_other
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
private
|
|
341
|
+
|
|
342
|
+
def scoped_resources
|
|
343
|
+
super.includes(:report, :appellant)
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
Notice what's **not** here: no content-removal SQL, no `user.suspend!`, no email sending, no audit write. All of that is the gem's job, triggered atomically inside `resolve!` / `dismiss!` / `uphold!` / `reject!`. Your controller is pure HTTP — params in, decision method called, redirect out. (That's why every action is a four-liner with a `rescue` for the flash.)
|
|
350
|
+
|
|
351
|
+
> [!IMPORTANT]
|
|
352
|
+
> Use `status: :see_other` on the redirects. The decision actions are `POST`s, and Turbo needs a 303 to follow a redirect after a non-GET. This is the same convention madmin's own create/update use.
|
|
353
|
+
|
|
354
|
+
### Reuse from the standard `Moderate::Moderation` concern instead
|
|
355
|
+
|
|
356
|
+
If you'd rather not hand-write the four controllers, `moderate` ships a controller concern that gives you the `resolve`/`dismiss` (and appeal `uphold`/`reject`) actions, strong params, and redirects already wired — you just bring auth:
|
|
357
|
+
|
|
358
|
+
```ruby
|
|
359
|
+
module Madmin
|
|
360
|
+
class ReportsController < Madmin::ResourceController
|
|
361
|
+
include Moderate::Moderation # resolve!/dismiss! actions, strong params, redirects
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
Hand-rolling (above) is the right call when your redirects/flashes need to match the rest of your madmin app; the concern is the right call when you want zero boilerplate. They do the same thing.
|
|
367
|
+
|
|
368
|
+
---
|
|
369
|
+
|
|
370
|
+
## Step 4 — The decision buttons (the show view)
|
|
371
|
+
|
|
372
|
+
`madmin`'s stock `show` template lists attributes; add a small panel with the decision forms. The cleanest move is a custom show view at `app/views/madmin/reports/show.html.erb` that renders madmin's default attributes and then a "Decide" sidebar. The load-bearing part is just two forms that POST to the member routes — render them only while the record is still `pending`:
|
|
373
|
+
|
|
374
|
+
```erb
|
|
375
|
+
<%# app/views/madmin/reports/show.html.erb (decision panel; render alongside madmin's default attribute list) %>
|
|
376
|
+
<% report = @record %>
|
|
377
|
+
|
|
378
|
+
<% if report.pending? %>
|
|
379
|
+
<section>
|
|
380
|
+
<h2>Resolve</h2>
|
|
381
|
+
<%= form_with url: resolve_madmin_report_path(report), method: :post do %>
|
|
382
|
+
<label><%= check_box_tag :remove_content, "1" %> Remove reported content</label>
|
|
383
|
+
<label><%= check_box_tag :ban_user, "1" %> Ban reported user</label>
|
|
384
|
+
<%= text_area_tag :note, nil, placeholder: "Decision note", required: true %>
|
|
385
|
+
<%= submit_tag "Resolve", data: { turbo_confirm: "Resolve and notify the parties?" } %>
|
|
386
|
+
<% end %>
|
|
387
|
+
|
|
388
|
+
<h2>Dismiss</h2>
|
|
389
|
+
<%= form_with url: dismiss_madmin_report_path(report), method: :post do %>
|
|
390
|
+
<%= text_area_tag :note, nil, placeholder: "Decision note", required: true %>
|
|
391
|
+
<%= submit_tag "Dismiss", data: { turbo_confirm: "Dismiss and notify the reporter?" } %>
|
|
392
|
+
<% end %>
|
|
393
|
+
</section>
|
|
394
|
+
<% else %>
|
|
395
|
+
<p>This report is closed.</p>
|
|
396
|
+
<% end %>
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
The appeal show view is the same shape with `uphold`/`reject`:
|
|
400
|
+
|
|
401
|
+
```erb
|
|
402
|
+
<% appeal = @record %>
|
|
403
|
+
<% if appeal.pending? %>
|
|
404
|
+
<%= form_with url: uphold_madmin_appeal_path(appeal), method: :post do %>
|
|
405
|
+
<%= text_area_tag :note, nil, placeholder: "Why the original decision is overturned", required: true %>
|
|
406
|
+
<%= submit_tag "Uphold appeal", data: { turbo_confirm: "Overturn the original decision?" } %>
|
|
407
|
+
<% end %>
|
|
408
|
+
<%= form_with url: reject_madmin_appeal_path(appeal), method: :post do %>
|
|
409
|
+
<%= text_area_tag :note, nil, placeholder: "Why the original decision stands", required: true %>
|
|
410
|
+
<%= submit_tag "Reject appeal", data: { turbo_confirm: "Confirm the original decision?" } %>
|
|
411
|
+
<% end %>
|
|
412
|
+
<% end %>
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
Two details worth copying:
|
|
416
|
+
|
|
417
|
+
- **Gate on `pending?`.** Show the decision forms only while the record is open; render "closed" once it isn't. The decision methods are also guarded server-side (calling `resolve!` on an already-resolved report raises), but hiding the buttons is the better UX.
|
|
418
|
+
- **Require the note.** `required: true` on the textarea, and the gem requires it too — every decision must carry a rationale (that's what feeds the DSA Art. 17 statement of reasons). Belt and suspenders.
|
|
419
|
+
|
|
420
|
+
The evidence snapshot (`report.snapshot`) is plain JSON on the record — render it in a `<pre>` so the moderator sees exactly what was reported, even if the original content was since edited or deleted. That immutability is the whole point of the snapshot.
|
|
421
|
+
|
|
422
|
+
---
|
|
423
|
+
|
|
424
|
+
## The moderation-queue pattern (the index)
|
|
425
|
+
|
|
426
|
+
Your `index` *is* the queue. Three things make it usable:
|
|
427
|
+
|
|
428
|
+
1. **Default to pending.** The `scope :pending` you declared on each resource gives madmin a one-click filter; make it the landing view by sorting `created_at desc` and pointing your "Moderation" nav link at `madmin_reports_path(scope: :pending)`.
|
|
429
|
+
2. **One sidebar group.** All four resources under one `parent:` menu (`"Trust & Safety"`) so reports, flags, appeals, and blocks read as a single workspace.
|
|
430
|
+
3. **A queue-depth dashboard tile.** The same scopes power an at-a-glance count on your admin home:
|
|
431
|
+
|
|
432
|
+
```ruby
|
|
433
|
+
# app/controllers/madmin/dashboard_controller.rb
|
|
434
|
+
def show
|
|
435
|
+
@pending_reports = Moderate::Report.pending.count
|
|
436
|
+
@pending_flags = Moderate::Flag.pending.count
|
|
437
|
+
@pending_appeals = Moderate::Appeal.pending.count
|
|
438
|
+
end
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
```erb
|
|
442
|
+
<%= link_to "#{@pending_reports} reports awaiting review", madmin_reports_path(scope: :pending) %>
|
|
443
|
+
<%= link_to "#{@pending_flags} flags awaiting review", madmin_flags_path(scope: :pending) %>
|
|
444
|
+
<%= link_to "#{@pending_appeals} appeals awaiting review", madmin_appeals_path(scope: :pending) %>
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
That's the whole moderation queue: pending-first lists, grouped nav, and a count on the dashboard — all built from the gem's `pending` scopes and your existing madmin.
|
|
448
|
+
|
|
449
|
+
---
|
|
450
|
+
|
|
451
|
+
## Automated review: the same `pending` queue
|
|
452
|
+
|
|
453
|
+
The headline trick of the model design: **a human admin and an ML/automation consumer read the *same* `Moderate::Flag.pending` (or `Moderate::Report.pending`) scope.** Your madmin screen is one reader; a background job is another. To auto-action high-confidence flags before a human ever sees them, drain the same queue in a job and call the same decision method:
|
|
454
|
+
|
|
455
|
+
```ruby
|
|
456
|
+
class AutoModerationJob < ApplicationJob
|
|
457
|
+
def perform
|
|
458
|
+
Moderate::Flag.pending.find_each do |flag|
|
|
459
|
+
next unless flag.scores[:csam].to_f >= 0.99 # only the unambiguous ones
|
|
460
|
+
flag.resolve!(by: Moderate.system_actor, note: "Auto-removed: high-confidence CSAM")
|
|
461
|
+
end
|
|
462
|
+
end
|
|
463
|
+
end
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
Because the job calls `resolve!` (not a raw `update`), the auto-decision is identical to a human one — atomic enforcement, `notify`/`audit` hooks, statement-of-reasons, appeal window. Whatever the job doesn't touch stays in `pending` for a human in madmin. One queue, two consumers, zero divergence.
|
|
467
|
+
|
|
468
|
+
---
|
|
469
|
+
|
|
470
|
+
## What `moderate` ships vs. what you build
|
|
471
|
+
|
|
472
|
+
To be explicit about the boundary this guide sits on:
|
|
473
|
+
|
|
474
|
+
| `moderate` ships (the primitives) | You build (the UI chrome) |
|
|
475
|
+
| --- | --- |
|
|
476
|
+
| The models (`Report`/`Flag`/`Appeal`/`Block`) | The madmin resources (columns, labels, fields) |
|
|
477
|
+
| The queue scopes (`.pending`, `.resolved`, …) | The index/show screens & nav grouping |
|
|
478
|
+
| The decision methods (`resolve!`/`dismiss!`/`uphold!`/`reject!`) — atomic enforcement + notify + audit + appeal window | The buttons/forms that call them |
|
|
479
|
+
| The optional `Moderate::Moderation` controller concern | Auth (`current_user`, admin gate) |
|
|
480
|
+
| Helpers + the evidence snapshot on each record | Your branding, layout, extra columns |
|
|
481
|
+
|
|
482
|
+
You wire it once and you have a real, audited moderation queue, in your own admin, in an afternoon.
|
|
483
|
+
|
|
484
|
+
## See also
|
|
485
|
+
|
|
486
|
+
- [Admin & the moderation queue](../README.md#️-admin--the-moderation-queue) — the short version in the README
|
|
487
|
+
- [Configuration reference](configuration.md) — `ban_handler`, `notify`, `audit`, filter policies
|
|
488
|
+
- [Notifications & audit](../README.md#-notifications---audit--one-hook-each) — what fires when you call a decision method
|
|
489
|
+
- [The DSA notice form](dsa-notice-form.md) — the public Art. 16 intake that lands in the same `Moderate::Report.pending` queue
|
|
490
|
+
- [`madmin`](https://github.com/excid3/madmin) — the admin framework this guide targets
|