moderate 1.0.0.beta2 → 1.0.0
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 +96 -1
- data/.simplecov +2 -10
- data/AGENTS.md +1 -1
- data/Appraisals +10 -8
- data/CHANGELOG.md +654 -52
- data/CLAUDE.md +1 -1
- data/README.md +147 -7
- data/Rakefile +2 -0
- data/app/controllers/concerns/moderate/moderation.rb +23 -11
- data/app/controllers/moderate/appeals_controller.rb +9 -5
- data/app/controllers/moderate/application_controller.rb +3 -3
- data/app/controllers/moderate/notices_controller.rb +3 -6
- data/app/helpers/moderate/engine_helper.rb +32 -11
- data/app/views/moderate/appeals/new.html.erb +6 -0
- data/app/views/moderate/transparency_reports/_summary_card.html.erb +2 -2
- data/app/views/moderate/transparency_reports/show.html.erb +5 -5
- data/config/locales/en.yml +200 -0
- data/config/locales/es.yml +177 -0
- data/db/migrate/20261018200000_add_pending_key_to_moderate_flags.rb +139 -0
- data/db/migrate/20261018210000_add_retention_and_classifications_to_moderate_tables.rb +103 -0
- data/docs/chats.md +140 -0
- data/docs/compliance.md +92 -3
- data/docs/configuration.md +30 -2
- data/docs/dsa-notice-form.md +1 -1
- data/docs/madmin.md +44 -14
- data/docs/notifications.md +17 -4
- data/examples/aws_rekognition_adapter.rb +6 -6
- data/examples/openai_moderation_adapter.rb +1 -1
- data/examples/regexp_pattern_adapter.rb +68 -0
- data/gemfiles/{rails_7.1.gemfile → rails_8.0.gemfile} +8 -6
- data/gemfiles/rails_8.1.gemfile +6 -5
- data/lib/generators/moderate/install_generator.rb +25 -0
- data/lib/generators/moderate/templates/create_moderate_tables.rb.erb +17 -2
- data/lib/generators/moderate/templates/initializer.rb +9 -15
- data/lib/generators/moderate/views_generator.rb +3 -3
- data/lib/moderate/attachments.rb +137 -0
- data/lib/moderate/configuration.rb +87 -12
- data/lib/moderate/engine.rb +86 -25
- data/lib/moderate/event.rb +21 -0
- data/lib/moderate/filters/base.rb +1 -0
- data/lib/moderate/filters/wordlist.rb +5 -2
- data/lib/moderate/jobs/classify_job.rb +96 -16
- data/lib/moderate/label.rb +2 -2
- data/lib/moderate/macros.rb +8 -3
- data/lib/moderate/models/appeal.rb +10 -1
- data/lib/moderate/models/block.rb +23 -19
- data/lib/moderate/models/classification.rb +24 -0
- data/lib/moderate/models/concerns/actor.rb +17 -2
- data/lib/moderate/models/concerns/content_filterable.rb +181 -55
- data/lib/moderate/models/concerns/deferred_notices.rb +90 -0
- data/lib/moderate/models/concerns/reportable.rb +84 -4
- data/lib/moderate/models/flag.rb +182 -19
- data/lib/moderate/models/report.rb +180 -16
- data/lib/moderate/result.rb +3 -10
- data/lib/moderate/retention.rb +241 -0
- data/lib/moderate/services/intake_notice.rb +10 -10
- data/lib/moderate/services/intake_report.rb +42 -13
- data/lib/moderate/services/resolve_appeal.rb +8 -2
- data/lib/moderate/services/resolve_flag.rb +183 -35
- data/lib/moderate/services/resolve_report.rb +288 -35
- data/lib/moderate/text_validator.rb +15 -14
- data/lib/moderate/version.rb +1 -1
- data/lib/moderate.rb +337 -17
- data/lib/tasks/moderate.rake +23 -0
- metadata +22 -14
- data/gemfiles/rails_7.2.gemfile +0 -36
- data/lib/moderate/text.rb +0 -38
- data/lib/moderate/word_list.rb +0 -90
data/CLAUDE.md
CHANGED
|
@@ -2,6 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
4
|
|
|
5
|
-
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.
|
|
5
|
+
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`, `docs/chats.md`) for the detailed integration guides.
|
|
6
6
|
|
|
7
7
|
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.
|
data/README.md
CHANGED
|
@@ -66,6 +66,8 @@ That's the whole idea: **the messy, legally-loaded plumbing every social/UGC app
|
|
|
66
66
|
|
|
67
67
|
## Quickstart
|
|
68
68
|
|
|
69
|
+
`moderate` needs Ruby 3.3+ and Rails 8.0.5.1+ (8.0 and 8.1 are tested), on SQLite, PostgreSQL or MySQL.
|
|
70
|
+
|
|
69
71
|
Add the gem:
|
|
70
72
|
|
|
71
73
|
```ruby
|
|
@@ -173,9 +175,18 @@ Post.where.not(user_id: Moderate.blocked_ids_for(current_user))
|
|
|
173
175
|
```ruby
|
|
174
176
|
current_user.report!(@message, category: :harassment, details: "Won't stop messaging me")
|
|
175
177
|
current_user.report!(@user, category: :impersonation)
|
|
178
|
+
current_user.report!(@message, category: :spam, block: true) # "Report and block"
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
`moderate` snapshots the offending content at report time (so evidence survives edits/deletes), infers who's responsible, records what kind of content it is (`moderation_content_type`), sends the reporter a receipt, and drops it in the queue. A report is refused when the reporter may not report the content (the reportable's own `report_visible_to?`: never your own content, never a conversation you're not in), whether or not the link was hidden.
|
|
182
|
+
|
|
183
|
+
Your report form's category select is one line, labeled in the reader's language (the gem ships English and Spanish for every value it defines, and your locale files win):
|
|
184
|
+
|
|
185
|
+
```erb
|
|
186
|
+
<%= f.select :category, Moderate::Report.category_options %> <%# honors config.report_categories %>
|
|
176
187
|
```
|
|
177
188
|
|
|
178
|
-
`
|
|
189
|
+
`Moderate.taxonomy_label(kind, value)` labels the rest of the gem's vocabulary the same way in your queue: `Moderate.taxonomy_label(:flag_status, flag.status)`, `(:legal_reason, report.legal_reason)`, `(:decision, report.decision_visibility)`, and so on.
|
|
179
190
|
|
|
180
191
|
## 🚩 Reportable content
|
|
181
192
|
|
|
@@ -193,6 +204,37 @@ end
|
|
|
193
204
|
|
|
194
205
|
*(Explicit-include equivalent: `include Moderate::Reportable` + `reportable_fields :title, :description`.)*
|
|
195
206
|
|
|
207
|
+
Give each field the name a human should see. Without a label, a report of a chat message reaches your moderators as «Body» and they have to guess what was reported — declare it once, next to the field:
|
|
208
|
+
|
|
209
|
+
```ruby
|
|
210
|
+
class Message < ApplicationRecord
|
|
211
|
+
has_reportable_content :body, :files, labels: { body: "Chat message", files: "Chat photo" }
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
report.reported_field_label # => "Chat message"
|
|
215
|
+
flag.field_label # => "Chat message"
|
|
216
|
+
Moderate.reportable_field_label(Message, :body) # => "Chat message" (also takes a record, or a "Message" type string)
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
`moderate` prints the label wherever it names a field — the public appeal page, the statement-of-reasons payload (`reported_field_label`), the `report_received` / `content_flagged` summaries — and keeps the raw name next to it (`reported_field`, `field`) for your code. Labels are optional and per field; each field resolves to the first of:
|
|
220
|
+
|
|
221
|
+
1. its `labels:` entry — a String, or a lambda evaluated at read time (`-> { I18n.t("chat.message") }`);
|
|
222
|
+
2. `moderate.reportable_fields.<model>.<field>` in your locale files, where `<model>` is the model's `model_name.i18n_key` (`chats/message` for `Chats::Message`; STI children also try their reportable parent);
|
|
223
|
+
3. `moderate.reportable_fields.<field>` — one label for that field name on every model. `moderate` ships `en` and `es` defaults for the common ones (`body` → "Text" / "Texto", `avatar` → "Profile photo" / "Foto de perfil", …), and your locale files override them;
|
|
224
|
+
4. `field.humanize`.
|
|
225
|
+
|
|
226
|
+
```yaml
|
|
227
|
+
# config/locales/es.yml — the multi-locale way to say the same thing
|
|
228
|
+
es:
|
|
229
|
+
moderate:
|
|
230
|
+
reportable_fields:
|
|
231
|
+
chats/message:
|
|
232
|
+
body: "Mensaje de chat"
|
|
233
|
+
files: "Foto de chat"
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Model-scoped and field-wide keys share one namespace, so a model whose key matches a field name (`Listing` and a `listing` field) can't also have a field-wide label for that name — scope it to the model instead. A whole-record report has no field, so its label is `nil`.
|
|
237
|
+
|
|
196
238
|
You get:
|
|
197
239
|
|
|
198
240
|
```ruby
|
|
@@ -208,6 +250,14 @@ Drop a report link into any view with the helper (it renders nothing if the view
|
|
|
208
250
|
<%= moderate_report_link(@listing, field: :description) %>
|
|
209
251
|
```
|
|
210
252
|
|
|
253
|
+
For markup rendered once and shown to many viewers (a chat bubble broadcast to everyone in the thread, a cached fragment), where the per-viewer check can't run, use the viewer-agnostic path and hide it on the author's own content client-side; the server still refuses a report the viewer may not make:
|
|
254
|
+
|
|
255
|
+
```erb
|
|
256
|
+
<%= link_to "Report", moderate_report_target_path(message, field: :body) %> <%# target: message.to_moderation_sgid, never expires %>
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
Using [`chats`](https://github.com/rameerez/chats) for messages? Its messages already speak this contract; the whole integration is in [`docs/chats.md`](docs/chats.md).
|
|
260
|
+
|
|
211
261
|
Because `moderate` is UI-agnostic, it does not render a built-in "under review" badge. Use `flagged?` / `flagged?(:field)` to render copy that fits your product when `:flag` mode lets content through but queues it for review.
|
|
212
262
|
|
|
213
263
|
If your app runs inside Hotwire Native / Turbo Native, remember that native path configuration is host-owned. Add rules for the in-app report routes you mount (for example `/reports/new` **and** the form action `/reports`, so validation errors stay in the same modal stack) and for the engine's public legal routes **and their form actions** such as `<mount>/notices/new`, `<mount>/notices`, `<mount>/appeals/new`, `<mount>/appeals`, and `<mount>/transparency` — where `<mount>` is wherever you mounted `Moderate::Engine` in your routes (it is host-chosen, not fixed). `moderate` can provide the Rails routes; your native shell still decides whether they push, present modally, use a sheet, and which Android `uri` maps to the destination.
|
|
@@ -235,6 +285,8 @@ end
|
|
|
235
285
|
|
|
236
286
|
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.
|
|
237
287
|
|
|
288
|
+
**Active Storage attachments** (`has_one_attached` / `has_many_attached`) take the same one line, `moderates :avatar, with: :image, mode: :flag`, and no other wiring. The file is classified once it is on the storage service, never before, whichever of `moderates` and `has_one_attached` you declare first, and a failed upload is never silent. Just before Active Storage uploads, `moderate` queues a `Moderate::ClassifyJob` for 5 seconds later; once the upload returns, it classifies right away (inline for a synchronous adapter, by an immediate job for an async one) if the file is there. Whichever comes first claims the files (a row in the gem's `moderate_classifications` table, written under the content record's row lock, held only for the claim), calls your adapter outside any lock, and marks the row classified; the other sees the claim or the mark and stops. So each set of files is classified once per record (a changed set, or the same file on another record, is classified again), and a slow provider never blocks writes to the content's row. A claim expires after `config.classification_claim_ttl` (10 minutes), so a worker that died mid-call doesn't leave the files unclassified. A file that isn't there (a direct upload still in flight, an upload that raised) is left to the job, which looks again 5, 10, 15 and 20 seconds later, then gives up with a `classification_skipped` audit event instead of handing your adapter a missing file. Under `mode: :block`, an attachment whose direct upload hasn't finished fails validation ("Photo hasn't finished uploading yet", en/es, key `errors.messages.upload_not_finished`) rather than being checked without its file: nothing enters unverified.
|
|
289
|
+
|
|
238
290
|
Check content directly anywhere:
|
|
239
291
|
|
|
240
292
|
```ruby
|
|
@@ -296,16 +348,22 @@ Moderate::Appeal.pending # appeals awaiting a human
|
|
|
296
348
|
|
|
297
349
|
report.resolve!(by: admin, remove_content: true, ban_user: false, note: "Removed: hate speech")
|
|
298
350
|
report.dismiss!(by: admin, note: "No violation")
|
|
351
|
+
flag.resolve!(by: admin, note: "Slur", remove_content: true) # enforces through a report (below)
|
|
352
|
+
flag.dismiss!(by: admin, note: "False positive")
|
|
299
353
|
appeal.uphold!(by: admin, note: "...") # overturns the decision
|
|
300
354
|
appeal.reject!(by: admin, note: "...")
|
|
355
|
+
|
|
356
|
+
Moderate::Flag.flag_for_review!(message, field: :body, by: admin) # "Flag for review" from any browse screen
|
|
301
357
|
```
|
|
302
358
|
|
|
303
|
-
Every action 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`), and writes to your audit log.
|
|
359
|
+
Every action 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`), and writes to your audit log. A ban needs a `ban_handler`: without one, `ban_user: true` raises instead of telling someone their account was suspended when nothing suspended it. And a report's `resolve!` restricts something (`remove_content:`, `ban_user:`, or both): one with neither is refused, because its statement of reasons would claim a restriction nobody imposed; a decision that restricts nothing is a `dismiss!`.
|
|
360
|
+
|
|
361
|
+
A flag has no reporter, statement of reasons or appeal window, but removing content or suspending an account is a restriction that needs all three. So `remove_content:` / `ban_user:` on a flag escalate it: the open report on the same content and field (if a person reported it too) or a system one is resolved under the flag's lock, and `flag.report` is that report afterwards. Without them, resolving a flag only triages it.
|
|
304
362
|
|
|
305
363
|
### Use it from a controller (BYOUI)
|
|
306
364
|
|
|
307
365
|
```ruby
|
|
308
|
-
class Admin::ReportsController < ApplicationController
|
|
366
|
+
class Admin::ReportsController < ApplicationController # the same concern drives Admin::FlagsController
|
|
309
367
|
include Moderate::Moderation # resolve!/dismiss! actions, strong params, redirects
|
|
310
368
|
|
|
311
369
|
before_action :require_admin
|
|
@@ -348,7 +406,9 @@ end
|
|
|
348
406
|
|
|
349
407
|
Events carry a stable envelope (`event.name`, `event.subject`, `event.recipients`, `event.actor`, `event.payload`), so a single `notify` hook can drive **goodmail** (user emails), **telegrama** (admin alerts), and **noticed** (in-app feed + push) at once. Notify users via email/in-app **and** ping admins on Telegram from the same place. (Recipes in [`docs/notifications.md`](docs/notifications.md).)
|
|
350
408
|
|
|
351
|
-
|
|
409
|
+
**Notices follow Active Record's `after_commit` rules.** A decision, a report's receipt, a block: each notice belongs to the transaction it happened in and is sent when that transaction commits, never if it rolls back. If you decide inside a transaction of your own, nothing goes out until yours commits; inside nested transactions and savepoints, at the outermost commit; under a parent that isn't joinable (Rails' transactional tests run every test in one), when the savepoint is released, the same moment your models' `after_commit` runs; with no transaction open, at once. Under multiple databases or shards, queue a notice inside the transaction that saved the record, on that transaction's connection: the gem's services capture their connection before they save and pass it along, so a host callback that switches databases mid-save can't move them; if you call `record.after_commit_notice { … }` yourself under `connected_to`, pass `on: connection` (it defaults to the record class's connection at call time).
|
|
410
|
+
|
|
411
|
+
The full event vocabulary: `report_received`, `notice_received`, `report_decision`, `affected_user_decision`, `appeal_received`, `appeal_decision`, `user_blocked`, `user_unblocked`, `user_banned`, `content_flagged`, `content_removed`.
|
|
352
412
|
|
|
353
413
|
## ⚖️ DSA & app-store compliance, out of the box
|
|
354
414
|
|
|
@@ -358,6 +418,7 @@ The full event vocabulary: `report_received`, `report_decision`, `affected_user_
|
|
|
358
418
|
- **DSA Art. 17 (statement of reasons):** decision notices state the action, the legal/contractual ground, whether automated means were used, and the redress path.
|
|
359
419
|
- **DSA Art. 20 (appeals):** a free, electronic internal complaint mechanism, open ≥ 6 months, decided by a human.
|
|
360
420
|
- **DSA Art. 24 (transparency):** counters you can publish (notices received, actions taken, median handling time, appeal outcomes). The public transparency page is **opt-in** (`config.transparency_report_enabled = true`, off by default) — a live portal isn't itself required (the duty is to *publish* a report, and micro/small enterprises are exempt), so you turn it on only when you want it.
|
|
421
|
+
- **Retention (GDPR storage limitation):** nothing is purged unless you ask. `Moderate.purge(older_than: 1.year)` (or `bin/rails moderate:purge`, opt-in, safe to run nightly) redacts the content copies and contact details from reports, flags and appeals whose decision is final, and keeps every decision and every transparency count. `Moderate.hold(report, until:)` keeps a report under external dispute out of it. What is stored, for how long, and why: [`docs/compliance.md#5-retention-what-moderate-keeps-and-for-how-long`](docs/compliance.md#5-retention-what-moderate-keeps-and-for-how-long).
|
|
361
422
|
- **Apple Guideline 1.2 & Google Play UGC:** filter-before-post, in-app report **and** block, ongoing moderation, published contact — `moderate` covers all four. See the mapped checklist in [`docs/compliance.md`](docs/compliance.md).
|
|
362
423
|
|
|
363
424
|
> Two taxonomies, on purpose: an in-app **community report** category set (harassment, spam, …) and a separate, regulator-aligned **DSA legal-reason** taxonomy for public notices. `moderate` ships both. The community set is host-customizable via `config.report_categories`; the DSA legal-reason taxonomy is regulator-defined and fixed.
|
|
@@ -373,7 +434,7 @@ The full event vocabulary: `report_received`, `report_decision`, `affected_user_
|
|
|
373
434
|
|
|
374
435
|
> The value-list taxonomies (community `category`, `status`, `content_type`, the DSA `legal_reason`/`legal_country_code`, `resolution_basis`, plus `Flag` source/mode/status and `Appeal` source/status) are validated **in the models** — frozen constants + ActiveModel `inclusion` validations — **not** by database `CHECK` constraints. That means **adding or customizing a label never requires a migration**: the community category list is host-overridable via `config.report_categories` (defaults to `Moderate::Report::DEFAULT_CATEGORIES`), and the gem can grow its own taxonomies in a point release without touching your schema. The only value guard kept at the DB level is a cheap message-length backstop; everything else the migration adds is structural (NOT NULLs, FKs, the unique block edge, and the self-block CHECK).
|
|
375
436
|
|
|
376
|
-
The migration is **adaptive**: it matches your app's primary-key type (UUID or bigint) and JSON column type (`jsonb` / `json`) automatically, so it drops cleanly into any Rails
|
|
437
|
+
The migration is **adaptive**: it matches your app's primary-key type (UUID or bigint) and JSON column type (`jsonb` / `json`) automatically, so it drops cleanly into any Rails 8.0+ schema.
|
|
377
438
|
|
|
378
439
|
## Configuration reference
|
|
379
440
|
|
|
@@ -386,7 +447,7 @@ Moderate.configure do |config|
|
|
|
386
447
|
config.audit = ->(event) { ... } # optional; no-op by default
|
|
387
448
|
config.notify = ->(event) { ... } # optional; no-op by default
|
|
388
449
|
config.on_block = ->(blocker:, blocked:, at:) { ... } # optional
|
|
389
|
-
config.ban_handler = ->(user:, by:, reason:) { user.suspend! } # how a "ban" is applied in your app
|
|
450
|
+
config.ban_handler = ->(user:, by:, reason:) { user.suspend! } # how a "ban" is applied in your app (required before any ban_user: true)
|
|
390
451
|
|
|
391
452
|
config.filter "Message", :body, with: :wordlist, mode: :flag
|
|
392
453
|
end
|
|
@@ -394,12 +455,90 @@ end
|
|
|
394
455
|
|
|
395
456
|
Reportable classes are auto-discovered from the `has_reportable_content` macro (or `include Moderate::Reportable`) — no manual registry.
|
|
396
457
|
|
|
458
|
+
## What is public
|
|
459
|
+
|
|
460
|
+
From 1.0 on, everything listed here keeps its name, its arguments and its meaning until 2.0. Anything not listed (and anything tagged `@api private` in the source) may change in any release. `test/public_api_test.rb` reads this section and fails if a name listed here doesn't exist, if a public `Moderate.*` method or configuration key isn't listed, or if the event lists drift from what the gem emits.
|
|
461
|
+
|
|
462
|
+
#### Declarations (`Moderate::Macros`, on every Active Record model)
|
|
463
|
+
|
|
464
|
+
`#has_reporting_and_blocking` · `#has_reportable_content` (`*fields, labels:`) · `#moderates` (`*fields, mode:, with:`). The include forms are equivalent: `Moderate::Actor`; `Moderate::Reportable` with `Moderate::Reportable::ClassMethods#reportable_fields`; `Moderate::ContentFilterable` with `Moderate::ContentFilterable::ClassMethods#moderates_fields`.
|
|
465
|
+
|
|
466
|
+
#### `Moderate::Actor` (your user model)
|
|
467
|
+
|
|
468
|
+
`#report!` (`reportable, category:, details: nil, block: false, field:, …`) · `#block!` · `#unblock!` · `#blocks?` · `#blocked_by?` · `#blocked_with?`
|
|
469
|
+
|
|
470
|
+
#### `Moderate::Reportable` (your content models)
|
|
471
|
+
|
|
472
|
+
What you define (each has a default): `#reported_owner` (required in practice) · `#moderation_label` · `#moderation_snapshot` · `#moderation_content_type` · `#report_visible_to?` · `#remove_reported_field!` · `#removable_reported_field?` · `#moderation_subject_url` · `#moderation_return_path` · `#moderation_admin_path`.
|
|
473
|
+
The removal seam's contract: `#remove_reported_field!(field)` removes that field's content as it is when called (clear it, purge the attachment, soft-delete the record so `deleted?` is true), persists that, and returns whether anything was removed; it must not rewrite the field to other content first. The gem verifies it: after a true return the field is read again under the same lock, and unless it reads as gone (through `#moderation_snapshot` and the field itself, or `deleted?`) the decision is refused as "nothing was removed".
|
|
474
|
+
What you get: `#reports` · `#open_reports` · `#reported?` · `#flagged?` · `#moderation_flags` · `#pending_moderation_flags` · `#to_moderation_sgid` · `#reportable_field_label` · `#reportable_field_allowed?` · `Moderate::Reportable::ClassMethods#reportable_field_label`.
|
|
475
|
+
|
|
476
|
+
#### `Moderate::ContentFilterable` (seams you may override)
|
|
477
|
+
|
|
478
|
+
`#moderation_field_value` · `#moderation_field_changed_for_commit?` · `#moderation_field_committed`
|
|
479
|
+
|
|
480
|
+
#### `Moderate` (the module)
|
|
481
|
+
|
|
482
|
+
`.configure` · `.config` · `.configuration` · `.reset!` · `.user_class` · `.reportable_classes` · `.classify` · `.filter_policy_for` · `.register_adapter` · `.blocked_ids_for` · `.responsible_user` · `.reportable_field_label` · `.taxonomy_label` · `.transparency` · `.delivered!` (`report, :decision | :affected_user, at:`) · `.purge` · `.hold` (`report, until:, by:, reason:`) · `.release_hold` · `Moderate::TAXONOMY_LABEL_SCOPES` · `Moderate::VERSION`
|
|
483
|
+
|
|
484
|
+
#### `Moderate::Report`
|
|
485
|
+
|
|
486
|
+
Queries: `.pending` · `.open` · `.actioned` · `.dismissed` · `.recent_first` · `.report_categories` · `.category_options` · `.locate_signed_reportable`.
|
|
487
|
+
Decisions: `#resolve!` (`by:, note:, remove_content: false, ban_user: false, resolution_basis: "terms"`) · `#dismiss!` (`by:, note:`).
|
|
488
|
+
Readers: `#pending?` · `#open?` · `#closed?` · `#actioned?` · `#dismissed?` · `#dsa?` · `#community?` · `#anonymous_notice?` · `#automated_processing_used?` · `#reported_field_label` · `#reportable_label` · `#notifier_label` · `#reported_content_text` · `#subject_url_list` · `#safe_subject_url` · `#safe_subject_urls` · `#safe_subject_url_for` · `#signed_appeal_gid` · `#held?` · `#covered_by` · `#skip_received_notice` · `#appeals` · `#flags`.
|
|
489
|
+
Value lists: `::DEFAULT_CATEGORIES` · `::DSA_LEGAL_REASONS` · `::EU_COUNTRY_CODES` · `::CONTENT_TYPES` · `::RESOLUTION_BASES` · `::STATUSES` · `::INTAKE_KINDS` · `::APPEAL_WINDOW` · `::MESSAGE_MAX_LENGTH`.
|
|
490
|
+
|
|
491
|
+
#### `Moderate::Flag`
|
|
492
|
+
|
|
493
|
+
`.pending` · `.actioned` · `.dismissed` · `.recent_first` · `.flag_for_review!` (`record, field:, by:, note: nil`) · `.flag!` (`flaggable:, field:, owner:, source:, mode:, excerpt:, categories:, scores:, context:`, for a host's own classifier pipeline) · `.sources` · `#action!` (`note:, by: nil, remove_content:, ban_user:, resolution_basis:`) · `#resolve!` (its alias) · `#dismiss!` (`note:, by: nil`) · `#pending?` · `#closed?` · `#report` · `#field_label` · `#flaggable_label` · `::STATUSES` · `::SOURCES` · `::MODES`
|
|
494
|
+
|
|
495
|
+
#### `Moderate::Appeal`
|
|
496
|
+
|
|
497
|
+
`.pending` · `.open` · `.upheld` · `.rejected` · `.recent_first` · `#uphold!` (`by:, note:`) · `#reject!` (`by:, note:`) · `#pending?` · `#open?` · `#closed?` · `#upheld?` · `#rejected?` · `::STATUSES` · `::SOURCES`
|
|
498
|
+
|
|
499
|
+
#### `Moderate::Block`
|
|
500
|
+
|
|
501
|
+
`.block!` (`blocker:, blocked:`) · `.unblock!` (`blocker:, blocked:`) · `.between` · `.recent_first`. Read the edge set with `Moderate.blocked_ids_for(user)`: always a relation of user ids, `.ids` for an Array.
|
|
502
|
+
|
|
503
|
+
#### Services (the long form of the decision methods, same keywords)
|
|
504
|
+
|
|
505
|
+
`Moderate::Services::ResolveReport` (`.new(report, by:)`, `#resolve!`, `#dismiss!`) · `Moderate::Services::ResolveFlag` (`.new(flag, by:)`, `#action!`, `#dismiss!`) · `Moderate::Services::ResolveAppeal` (`.new(appeal, by:)`, `#uphold!`, `#reject!`) · `Moderate::Services::IntakeReport` · `Moderate::Services::IntakeNotice` · `Moderate::Services::IntakeAppeal` (each `#save`).
|
|
506
|
+
|
|
507
|
+
#### Jobs, adapters and value objects
|
|
508
|
+
|
|
509
|
+
`Moderate::ClassifyJob` (`perform_later(record, field)`; `::UPLOAD_WAIT`, `::UPLOAD_ATTEMPTS`) · the adapter contract: any object with `classify(value)` returning a `Moderate::Result` (or a Hash of its fields), optionally `synchronous?`; `Moderate::Filters::Base` to inherit from · `Moderate::Filters::Wordlist` (`:wordlist`) · `Moderate::Result` (`.allowed`, `#allowed?`, `#flagged?`, `#categories`, `#scores`, `#labels`, `#source`, `#raw`) · `Moderate::Label` (`#slug`, `#canonical?`, `::CATEGORIES`) · `Moderate::Error` · `Moderate::ConfigurationError` · the 0.x validator `validates :field, moderate: true`.
|
|
510
|
+
|
|
511
|
+
#### Views and controllers
|
|
512
|
+
|
|
513
|
+
Helpers (`Moderate::EngineHelper`): `#moderate_report_link` · `#report_link` · `#moderate_report_target_path`, and the seam `#moderate_current_viewer`.
|
|
514
|
+
Admin decisions (`Moderate::Moderation`, a controller concern): `#resolve` · `#dismiss` · `#uphold` · `#reject`, and the seams `#moderation_record` · `#moderation_actor` · `#moderation_note` · `#moderation_decision_params` · `#moderation_redirect_path`.
|
|
515
|
+
The engine (`mount Moderate::Engine => "/trust"`): `new_notice_path` / `notices_path`, `new_appeal_path` / `appeals_path` (`token:`, `source:`), `transparency_path`; `record.after_commit_notice` (`Moderate::DeferredNotices#after_commit_notice`) on the gem's records. Generators: `moderate:install`, `moderate:views`. Rake: `moderate:purge`.
|
|
516
|
+
|
|
517
|
+
#### Configuration (`Moderate.configure do |config| … end`)
|
|
518
|
+
|
|
519
|
+
`config.user_class` · `config.default_filter_mode` · `config.filter_adapter` · `config.additional_words` · `config.excluded_words` · `config.report_categories` · `config.register_adapter` · `config.filter` · `config.audit` · `config.notify` · `config.on_block` · `config.ban_handler` · `config.banned` · `config.locale` · `config.classification_claim_ttl` · `config.migration_copies_check` · `config.parent_controller` · `config.notice_form_enabled` · `config.notice_rate_limit` · `config.notice_turnstile_site_key` · `config.notice_turnstile_secret_key` · `config.notice_captcha_verifier` · `config.notice_guard` · `config.notice_human_verification_skip_if` · `config.appeal_form_enabled` · `config.appeal_rate_limit` · `config.appeal_guard` · `config.appeal_human_verification_skip_if` · `config.appeal_return_path` · `config.transparency_report_enabled`.
|
|
520
|
+
Read-only: `config.adapters` · `config.filters` · `config.adapter_for` · `config.adapter_registered?` · `config.adapter_async?` · `config.ban_handler_configured?` · `config.validate!`.
|
|
521
|
+
|
|
522
|
+
#### Events and the notifier contract
|
|
523
|
+
|
|
524
|
+
Notify events (`Moderate::Event::NOTIFY_NAMES`): `:report_received` · `:notice_received` · `:report_decision` · `:affected_user_decision` · `:appeal_received` · `:appeal_decision` · `:user_blocked` · `:user_unblocked` · `:user_banned` · `:content_flagged` · `:content_removed`
|
|
525
|
+
|
|
526
|
+
Audit events (`Moderate::Event::AUDIT_NAMES`): `:report_received` · `:appeal_received` · `:report_decision` · `:flag_decision` · `:appeal_decision` · `:flag_filed` · `:user_blocked` · `:user_unblocked` · `:user_banned` · `:content_removed` · `:notify_failed` · `:classification_skipped` · `:retention_purged` · `:legal_hold_placed` · `:legal_hold_released`
|
|
527
|
+
|
|
528
|
+
Every hook receives one `Moderate::Event`: `#name` · `#subject` · `#actor` · `#recipients` · `#payload` (always with `:summary`) · `#occurred_at` · `#id` · `#summary` · `#to_h`. The contract for `config.notify`: it is called once per event, after the transaction that caused it commits (never for a rolled-back change); a truthy return means "handed off" (for a DSA notice the gem then treats the Art. 16(4) receipt as sent); a decision notice counts as delivered only when your notifier confirms it with `Moderate.delivered!(report, :decision | :affected_user)` from the mail's actual delivery, which stamps `decision_notified_at` / `affected_user_notified_at` once and starts the appeal window from then (DSA Art. 20(2)); an exception is swallowed, audited as `:notify_failed`, and never rolls back the decision. `config.audit` follows the same rules and its return value is ignored. Payload keys may be added in a minor release, never removed or renamed.
|
|
529
|
+
|
|
530
|
+
## Upgrading from a 1.0 beta
|
|
531
|
+
|
|
532
|
+
**1.0.0 adds a migration** (retention columns and `moderate_classifications`): run `bin/rails moderate:install:migrations` if your app holds copies of the gem's migrations (`*.moderate.rb`, as every app installed from 1.0.0.beta4 on does), then `bin/rails db:migrate`. An app without copies gets it through the engine, like beta4's. **A missed copy is no longer silent:** at boot, an app that holds copies but not one of this version's migrations raises `Moderate::ConfigurationError` naming it and the command that copies it (under a rake task, such as that command itself, it logs instead); `config.migration_copies_check = :log` or `:off` changes that.
|
|
533
|
+
|
|
534
|
+
**1.0.0.beta4 adds a migration.** After upgrading, run `bin/rails db:migrate`: the engine adds the gem's own `db/migrate` to your app's migration paths, so it runs with yours. (If your app holds copies of the gem's migrations, `*.moderate.rb` files from `bin/rails moderate:install:migrations`, the engine adds nothing and the copies run instead: run `bin/rails moderate:install:migrations` to copy the new one, then migrate.) A fresh install from 1.0.0.beta4 on gets the same migration from `rails generate moderate:install`, copied right after the tables, so it holds copies: after each later upgrade, run `bin/rails moderate:install:migrations`, then migrate. It gives `moderate_flags` a `pending_key` with a unique index, so at most one flag is pending per content and field. If flags filed before the upgrade would break that index, the migration stops and lists them; close all but one of each and run it again. On MySQL the key column is `utf8mb4_bin`, so keys compare by case; that collation still ignores trailing spaces (PAD SPACE), which is why `moderate` strips a flag's `field`, and why a legacy flag whose field ends in a space can be one of those the migration lists. See [`CHANGELOG.md`](CHANGELOG.md) for what each beta changes.
|
|
535
|
+
|
|
397
536
|
## Upgrading from 0.x
|
|
398
537
|
|
|
399
538
|
`moderate` 1.0 is a ground-up rewrite: the old gem was a profanity validator; 1.0 is a full Trust & Safety system. The one piece of the old API that remains is the validator, now backed by the `:wordlist` adapter:
|
|
400
539
|
|
|
401
540
|
```ruby
|
|
402
|
-
validates :body, moderate: true # still works —
|
|
541
|
+
validates :body, moderate: true # still works — the same check as `moderates :body, with: :wordlist, mode: :block`
|
|
403
542
|
```
|
|
404
543
|
|
|
405
544
|
Everything else is new. There's no automated data migration (0.x stored nothing). See [`CHANGELOG.md`](CHANGELOG.md).
|
|
@@ -410,6 +549,7 @@ We use Minitest. Run the suite (a dummy Rails app under `test/dummy`, against SQ
|
|
|
410
549
|
|
|
411
550
|
```bash
|
|
412
551
|
bundle exec rake test
|
|
552
|
+
bundle exec rubocop # CI expects zero offenses
|
|
413
553
|
```
|
|
414
554
|
|
|
415
555
|
## Development
|
data/Rakefile
CHANGED
|
@@ -26,8 +26,9 @@ module Moderate
|
|
|
26
26
|
|
|
27
27
|
# --- Report / Flag decisions ---------------------------------------------
|
|
28
28
|
# Both `Moderate::Report` and `Moderate::Flag` expose `resolve!`/`dismiss!` with
|
|
29
|
-
# the same keyword contract, so the SAME two
|
|
30
|
-
# the host just includes this concern in
|
|
29
|
+
# the same keyword contract (`Flag#resolve!` is `Flag#action!`), so the SAME two
|
|
30
|
+
# actions drive either resource — the host just includes this concern in
|
|
31
|
+
# whichever controller.
|
|
31
32
|
|
|
32
33
|
# Resolve (action) a report/flag: optionally remove the offending content and/or
|
|
33
34
|
# ban the responsible user, always with a moderator + a note.
|
|
@@ -39,8 +40,8 @@ module Moderate
|
|
|
39
40
|
def resolve
|
|
40
41
|
record = moderation_record
|
|
41
42
|
record.resolve!(**moderation_decision_params)
|
|
42
|
-
redirect_after_moderation(record, notice: moderation_t(:resolved))
|
|
43
|
-
rescue => error
|
|
43
|
+
redirect_after_moderation(record, notice: moderation_t(moderation_outcome_key(record, :resolved)))
|
|
44
|
+
rescue StandardError => error
|
|
44
45
|
redirect_after_moderation(record, alert: moderation_error(:resolve, error))
|
|
45
46
|
end
|
|
46
47
|
|
|
@@ -48,8 +49,8 @@ module Moderate
|
|
|
48
49
|
def dismiss
|
|
49
50
|
record = moderation_record
|
|
50
51
|
record.dismiss!(by: moderation_actor, note: moderation_note)
|
|
51
|
-
redirect_after_moderation(record, notice: moderation_t(:dismissed))
|
|
52
|
-
rescue => error
|
|
52
|
+
redirect_after_moderation(record, notice: moderation_t(moderation_outcome_key(record, :dismissed)))
|
|
53
|
+
rescue StandardError => error
|
|
53
54
|
redirect_after_moderation(record, alert: moderation_error(:dismiss, error))
|
|
54
55
|
end
|
|
55
56
|
|
|
@@ -62,7 +63,7 @@ module Moderate
|
|
|
62
63
|
record = moderation_record
|
|
63
64
|
record.uphold!(by: moderation_actor, note: moderation_note)
|
|
64
65
|
redirect_after_moderation(record, notice: moderation_t(:upheld))
|
|
65
|
-
rescue => error
|
|
66
|
+
rescue StandardError => error
|
|
66
67
|
redirect_after_moderation(record, alert: moderation_error(:uphold, error))
|
|
67
68
|
end
|
|
68
69
|
|
|
@@ -70,7 +71,7 @@ module Moderate
|
|
|
70
71
|
record = moderation_record
|
|
71
72
|
record.reject!(by: moderation_actor, note: moderation_note)
|
|
72
73
|
redirect_after_moderation(record, notice: moderation_t(:rejected))
|
|
73
|
-
rescue => error
|
|
74
|
+
rescue StandardError => error
|
|
74
75
|
redirect_after_moderation(record, alert: moderation_error(:reject, error))
|
|
75
76
|
end
|
|
76
77
|
|
|
@@ -100,14 +101,18 @@ module Moderate
|
|
|
100
101
|
# `params.require(:report)` because the decision form is a flat panel of
|
|
101
102
|
# checkboxes + a note, not a nested model form — so we read top-level params and
|
|
102
103
|
# hand the model exactly the keyword args it documents. `ban_user`/`remove_content`
|
|
103
|
-
# default to off so a missing checkbox never accidentally bans someone
|
|
104
|
+
# default to off so a missing checkbox never accidentally bans someone, and
|
|
105
|
+
# `resolution_basis` (the Art. 17 ground) is passed only when the form sent one,
|
|
106
|
+
# so the model's default ("terms") applies otherwise. On a Flag, either
|
|
107
|
+
# enforcement option escalates to a report (Flag#action!).
|
|
104
108
|
def moderation_decision_params
|
|
105
109
|
{
|
|
106
110
|
by: moderation_actor,
|
|
107
111
|
note: moderation_note,
|
|
108
112
|
remove_content: params[:remove_content],
|
|
109
|
-
ban_user: params[:ban_user]
|
|
110
|
-
|
|
113
|
+
ban_user: params[:ban_user],
|
|
114
|
+
resolution_basis: params[:resolution_basis].presence
|
|
115
|
+
}.compact
|
|
111
116
|
end
|
|
112
117
|
|
|
113
118
|
# Redirect back to the record after a decision.
|
|
@@ -146,12 +151,19 @@ module Moderate
|
|
|
146
151
|
moderation_t(:"#{action}_failed", default: "Could not #{action}: #{message}", error: message)
|
|
147
152
|
end
|
|
148
153
|
|
|
154
|
+
# A flag's outcome reads as a flag's ("Flag actioned."), a report's as a report's.
|
|
155
|
+
def moderation_outcome_key(record, outcome)
|
|
156
|
+
record.is_a?(Moderate::Flag) ? :"flag_#{outcome}" : outcome
|
|
157
|
+
end
|
|
158
|
+
|
|
149
159
|
# Translated flash copy, with a plain-English default so the concern works even
|
|
150
160
|
# before the host loads the gem's locale file.
|
|
151
161
|
def moderation_t(key, **options)
|
|
152
162
|
defaults = {
|
|
153
163
|
resolved: "Report resolved.",
|
|
154
164
|
dismissed: "Report dismissed.",
|
|
165
|
+
flag_resolved: "Flag actioned.",
|
|
166
|
+
flag_dismissed: "Flag dismissed.",
|
|
155
167
|
upheld: "Appeal upheld.",
|
|
156
168
|
rejected: "Appeal rejected."
|
|
157
169
|
}
|
|
@@ -11,7 +11,10 @@ module Moderate
|
|
|
11
11
|
|
|
12
12
|
def new
|
|
13
13
|
@report = Moderate::Report.locate_signed_appeal_report(params[:token])
|
|
14
|
-
|
|
14
|
+
if @report.blank?
|
|
15
|
+
return redirect_to appeal_return_path,
|
|
16
|
+
alert: t("moderate.appeals.not_found", default: "We couldn't find that moderation decision.")
|
|
17
|
+
end
|
|
15
18
|
|
|
16
19
|
@appeal = Moderate::Appeal.new(
|
|
17
20
|
report: @report,
|
|
@@ -24,7 +27,10 @@ module Moderate
|
|
|
24
27
|
|
|
25
28
|
def create
|
|
26
29
|
@report = Moderate::Report.locate_signed_appeal_report(params[:token])
|
|
27
|
-
|
|
30
|
+
if @report.blank?
|
|
31
|
+
return redirect_to appeal_return_path,
|
|
32
|
+
alert: t("moderate.appeals.not_found", default: "We couldn't find that moderation decision.")
|
|
33
|
+
end
|
|
28
34
|
|
|
29
35
|
attributes = appeal_params
|
|
30
36
|
attributes[:source] = appeal_source_for(@report, attributes[:source])
|
|
@@ -64,9 +70,7 @@ module Moderate
|
|
|
64
70
|
return @current_appellant if defined?(@current_appellant)
|
|
65
71
|
|
|
66
72
|
@current_appellant =
|
|
67
|
-
if respond_to?(:current_user, true)
|
|
68
|
-
current_user
|
|
69
|
-
end
|
|
73
|
+
(current_user if respond_to?(:current_user, true))
|
|
70
74
|
rescue StandardError
|
|
71
75
|
@current_appellant = nil
|
|
72
76
|
end
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
# Resolves the host's `config.parent_controller` once, for the engine's base controller.
|
|
3
4
|
module Moderate
|
|
4
5
|
# Base controller for the engine's OWN controllers (today: the public DSA notice
|
|
5
6
|
# form). It is NOT used by the host's in-app report/admin controllers — those are
|
|
@@ -32,14 +33,13 @@ module Moderate
|
|
|
32
33
|
::ActionController::Base
|
|
33
34
|
end
|
|
34
35
|
|
|
36
|
+
# The base of the engine's own controllers (notices, appeals, transparency).
|
|
35
37
|
class ApplicationController < parent
|
|
36
38
|
# CSRF protection — but only when the parent actually supports it. On
|
|
37
39
|
# `ActionController::API` (or a host base that doesn't include the module),
|
|
38
40
|
# `protect_from_forgery` isn't defined, so we guard the call. The public notice
|
|
39
41
|
# form is a state-changing POST, so we want forgery protection whenever it's
|
|
40
42
|
# available. `with: :exception` is the modern Rails default.
|
|
41
|
-
if respond_to?(:protect_from_forgery)
|
|
42
|
-
protect_from_forgery with: :exception
|
|
43
|
-
end
|
|
43
|
+
protect_from_forgery with: :exception if respond_to?(:protect_from_forgery)
|
|
44
44
|
end
|
|
45
45
|
end
|
|
@@ -154,9 +154,7 @@ module Moderate
|
|
|
154
154
|
return @current_notifier if defined?(@current_notifier)
|
|
155
155
|
|
|
156
156
|
@current_notifier =
|
|
157
|
-
if respond_to?(:current_user, true)
|
|
158
|
-
current_user
|
|
159
|
-
end
|
|
157
|
+
(current_user if respond_to?(:current_user, true))
|
|
160
158
|
rescue StandardError
|
|
161
159
|
# A host `current_user` that raises (e.g. a not-yet-migrated session) must not
|
|
162
160
|
# break the public notice form — fall back to "anonymous".
|
|
@@ -222,9 +220,8 @@ module Moderate
|
|
|
222
220
|
|
|
223
221
|
# A public, unauthenticated POST is a spam/abuse magnet, so we throttle per IP.
|
|
224
222
|
#
|
|
225
|
-
# Rails
|
|
226
|
-
#
|
|
227
|
-
# implement the throttle as a single `before_action` (rather than the class-level
|
|
223
|
+
# A tiny `Rails.cache`-backed counter rather than Rails' own `rate_limit` macro:
|
|
224
|
+
# we implement the throttle as a single `before_action` (not the class-level
|
|
228
225
|
# `rate_limit` macro) precisely so we can honor the host's RUNTIME
|
|
229
226
|
# `config.notice_rate_limit` (the macro is evaluated at class load, before the
|
|
230
227
|
# host's initializer has necessarily run).
|
|
@@ -65,6 +65,20 @@ module Moderate
|
|
|
65
65
|
moderate_report_link(record, field: field, label: label, **html_options)
|
|
66
66
|
end
|
|
67
67
|
|
|
68
|
+
# The path to the in-app report form for `record` (and `field`), with the record
|
|
69
|
+
# as a signed target — WITHOUT asking who is looking. For markup rendered once
|
|
70
|
+
# and shown to many viewers (a chat bubble broadcast to every participant, a
|
|
71
|
+
# fragment cache), where `moderate_report_link`'s per-viewer check can't run at
|
|
72
|
+
# render time: show it to everyone but the author on the client, and let the
|
|
73
|
+
# report be refused server-side (Report validates `report_visible_to?`).
|
|
74
|
+
#
|
|
75
|
+
# <%= link_to "Report", moderate_report_target_path(message, field: :body) %>
|
|
76
|
+
#
|
|
77
|
+
# The target never expires, so the URL is stable across renders.
|
|
78
|
+
def moderate_report_target_path(record, field: nil)
|
|
79
|
+
moderate_report_path_for(record, field: field)
|
|
80
|
+
end
|
|
81
|
+
|
|
68
82
|
private
|
|
69
83
|
|
|
70
84
|
# The path to the in-app intake form for this record/field.
|
|
@@ -87,15 +101,20 @@ module Moderate
|
|
|
87
101
|
target = moderate_signed_target(record)
|
|
88
102
|
query = { target: target, field: field.presence }.compact
|
|
89
103
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
104
|
+
# The host's route, from this view's own helpers first, then through
|
|
105
|
+
# `main_app` — inside ANOTHER isolated engine's views (a chats slot, say) the
|
|
106
|
+
# host's route helpers are only reachable that way.
|
|
107
|
+
route_sets = [self]
|
|
108
|
+
route_sets << main_app if respond_to?(:main_app)
|
|
109
|
+
route_sets.each do |routes|
|
|
110
|
+
%i[new_report_path new_moderate_report_path].each do |name|
|
|
111
|
+
return routes.public_send(name, query) if routes.respond_to?(name)
|
|
112
|
+
end
|
|
98
113
|
end
|
|
114
|
+
|
|
115
|
+
# Last-resort conventional path so the helper is never a hard dependency on
|
|
116
|
+
# a particular route name. The host wires the actual route (BYOUI).
|
|
117
|
+
"/reports/new?#{query.to_query}"
|
|
99
118
|
end
|
|
100
119
|
|
|
101
120
|
# The record as a signed, purpose-scoped GID parameter. We delegate to the
|
|
@@ -146,6 +165,8 @@ end
|
|
|
146
165
|
# Expose the helper to the HOST app's views (see the "isolated engine" gotcha in
|
|
147
166
|
# the module doc above). `on_load(:action_view)` defers until ActionView is loaded,
|
|
148
167
|
# so we never force it at boot and we play nice with the host's load order.
|
|
149
|
-
ActiveSupport
|
|
150
|
-
|
|
151
|
-
|
|
168
|
+
if defined?(ActiveSupport)
|
|
169
|
+
ActiveSupport.on_load(:action_view) do
|
|
170
|
+
include Moderate::EngineHelper
|
|
171
|
+
end
|
|
172
|
+
end
|
|
@@ -31,6 +31,12 @@
|
|
|
31
31
|
<strong><%= t("moderate.appeals.decision", default: "Decision") %></strong>
|
|
32
32
|
<span><%= @report.id %></span>
|
|
33
33
|
</p>
|
|
34
|
+
<% if (field_label = @report.reported_field_label) %>
|
|
35
|
+
<p class="moderate-reported-field">
|
|
36
|
+
<strong><%= t("moderate.appeals.reported_field", default: "Content") %></strong>
|
|
37
|
+
<span><%= field_label %></span>
|
|
38
|
+
</p>
|
|
39
|
+
<% end %>
|
|
34
40
|
<% if @report.appeal_deadline_at.present? %>
|
|
35
41
|
<p><%= t("moderate.appeals.deadline", default: "You can request a free internal review until %{deadline}. A person will review it.", deadline: l(@report.appeal_deadline_at, format: :long)) %></p>
|
|
36
42
|
<% end %>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<%
|
|
2
2
|
rows = rows.to_h.compact_blank
|
|
3
|
-
|
|
3
|
+
kind = local_assigns[:kind]
|
|
4
4
|
%>
|
|
5
5
|
|
|
6
6
|
<article class="moderate-card">
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
<dl>
|
|
10
10
|
<% rows.sort_by { |key, _value| key.to_s }.each do |key, value| %>
|
|
11
11
|
<div>
|
|
12
|
-
<dt><%=
|
|
12
|
+
<dt><%= kind ? Moderate.taxonomy_label(kind, key) : key.to_s.humanize %></dt>
|
|
13
13
|
<dd><%= value %></dd>
|
|
14
14
|
</div>
|
|
15
15
|
<% end %>
|
|
@@ -28,11 +28,11 @@
|
|
|
28
28
|
|
|
29
29
|
<main class="moderate-main">
|
|
30
30
|
<div class="moderate-container moderate-grid">
|
|
31
|
-
<%= render "summary_card", title: t("moderate.transparency.cards.notices_by_intake", default: "Notices by intake"), rows: @summary[:notices_by_intake] %>
|
|
32
|
-
<%= render "summary_card", title: t("moderate.transparency.cards.dsa_by_reason", default: "DSA notices by legal reason"), rows: @summary[:dsa_notices_by_legal_reason],
|
|
33
|
-
<%= render "summary_card", title: t("moderate.transparency.cards.actions_by_basis", default: "Actions by basis"), rows: @summary[:actions_by_basis],
|
|
34
|
-
<%= render "summary_card", title: t("moderate.transparency.cards.flags_by_source", default: "Automated flags by source"), rows: @summary[:automated_flags_by_source] %>
|
|
35
|
-
<%= render "summary_card", title: t("moderate.transparency.cards.appeals_by_status", default: "Appeals by status"), rows: @summary[:appeals_by_status] %>
|
|
31
|
+
<%= render "summary_card", title: t("moderate.transparency.cards.notices_by_intake", default: "Notices by intake"), rows: @summary[:notices_by_intake], kind: :intake_kind %>
|
|
32
|
+
<%= render "summary_card", title: t("moderate.transparency.cards.dsa_by_reason", default: "DSA notices by legal reason"), rows: @summary[:dsa_notices_by_legal_reason], kind: :legal_reason %>
|
|
33
|
+
<%= render "summary_card", title: t("moderate.transparency.cards.actions_by_basis", default: "Actions by basis"), rows: @summary[:actions_by_basis], kind: :resolution_basis %>
|
|
34
|
+
<%= render "summary_card", title: t("moderate.transparency.cards.flags_by_source", default: "Automated flags by source"), rows: @summary[:automated_flags_by_source], kind: :flag_source %>
|
|
35
|
+
<%= render "summary_card", title: t("moderate.transparency.cards.appeals_by_status", default: "Appeals by status"), rows: @summary[:appeals_by_status], kind: :appeal_status %>
|
|
36
36
|
|
|
37
37
|
<article class="moderate-card">
|
|
38
38
|
<h2><%= t("moderate.transparency.cards.median_times", default: "Median times") %></h2>
|