moderate 1.0.0.beta1 → 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.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +96 -1
  3. data/.simplecov +2 -10
  4. data/AGENTS.md +1 -1
  5. data/Appraisals +10 -8
  6. data/CHANGELOG.md +660 -20
  7. data/CLAUDE.md +1 -1
  8. data/README.md +149 -7
  9. data/Rakefile +2 -0
  10. data/app/controllers/concerns/moderate/moderation.rb +23 -11
  11. data/app/controllers/moderate/appeals_controller.rb +9 -5
  12. data/app/controllers/moderate/application_controller.rb +3 -3
  13. data/app/controllers/moderate/notices_controller.rb +3 -6
  14. data/app/helpers/moderate/engine_helper.rb +32 -11
  15. data/app/views/moderate/appeals/new.html.erb +6 -0
  16. data/app/views/moderate/transparency_reports/_summary_card.html.erb +2 -2
  17. data/app/views/moderate/transparency_reports/show.html.erb +5 -5
  18. data/config/locales/en.yml +200 -0
  19. data/config/locales/es.yml +177 -0
  20. data/context7.json +4 -0
  21. data/db/migrate/20261018200000_add_pending_key_to_moderate_flags.rb +139 -0
  22. data/db/migrate/20261018210000_add_retention_and_classifications_to_moderate_tables.rb +103 -0
  23. data/docs/chats.md +140 -0
  24. data/docs/compliance.md +92 -3
  25. data/docs/configuration.md +30 -2
  26. data/docs/dsa-notice-form.md +1 -1
  27. data/docs/images/moderate-user-report-block-actions.webp +0 -0
  28. data/docs/madmin.md +44 -14
  29. data/docs/notifications.md +17 -4
  30. data/examples/aws_rekognition_adapter.rb +6 -6
  31. data/examples/openai_moderation_adapter.rb +1 -1
  32. data/examples/regexp_pattern_adapter.rb +68 -0
  33. data/gemfiles/{rails_7.1.gemfile → rails_8.0.gemfile} +8 -6
  34. data/gemfiles/rails_8.1.gemfile +6 -5
  35. data/lib/generators/moderate/install_generator.rb +25 -0
  36. data/lib/generators/moderate/templates/create_moderate_tables.rb.erb +17 -2
  37. data/lib/generators/moderate/templates/initializer.rb +9 -15
  38. data/lib/generators/moderate/views_generator.rb +3 -3
  39. data/lib/moderate/attachments.rb +137 -0
  40. data/lib/moderate/configuration.rb +101 -12
  41. data/lib/moderate/engine.rb +86 -25
  42. data/lib/moderate/event.rb +21 -0
  43. data/lib/moderate/filters/base.rb +1 -0
  44. data/lib/moderate/filters/wordlist.rb +5 -2
  45. data/lib/moderate/jobs/classify_job.rb +100 -16
  46. data/lib/moderate/label.rb +2 -2
  47. data/lib/moderate/macros.rb +8 -3
  48. data/lib/moderate/models/appeal.rb +10 -1
  49. data/lib/moderate/models/block.rb +23 -19
  50. data/lib/moderate/models/classification.rb +24 -0
  51. data/lib/moderate/models/concerns/actor.rb +17 -2
  52. data/lib/moderate/models/concerns/content_filterable.rb +241 -56
  53. data/lib/moderate/models/concerns/deferred_notices.rb +90 -0
  54. data/lib/moderate/models/concerns/reportable.rb +84 -4
  55. data/lib/moderate/models/flag.rb +199 -3
  56. data/lib/moderate/models/report.rb +180 -16
  57. data/lib/moderate/result.rb +3 -10
  58. data/lib/moderate/retention.rb +241 -0
  59. data/lib/moderate/services/intake_notice.rb +10 -10
  60. data/lib/moderate/services/intake_report.rb +42 -13
  61. data/lib/moderate/services/resolve_appeal.rb +8 -2
  62. data/lib/moderate/services/resolve_flag.rb +183 -35
  63. data/lib/moderate/services/resolve_report.rb +288 -35
  64. data/lib/moderate/text_validator.rb +15 -14
  65. data/lib/moderate/version.rb +1 -1
  66. data/lib/moderate.rb +337 -17
  67. data/lib/tasks/moderate.rake +23 -0
  68. metadata +24 -14
  69. data/gemfiles/rails_7.2.gemfile +0 -36
  70. data/lib/moderate/text.rb +0 -38
  71. 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
@@ -11,6 +11,8 @@ Trust & Safety (T&S) is the system within an app that lets users **report** abus
11
11
 
12
12
  If you have an app where users can upload / generate content or send messages to each other, you probably need a Trust & Safety system.
13
13
 
14
+ ![Moderate gem sample use case for reporting profiles and blocking users](/docs/images/moderate-user-report-block-actions.webp)
15
+
14
16
  `moderate` ships with mechanisms aligned with the **DSA** (EU Digital Services Act), and also aligned with the **Apple App Store** and Android's **Google Play** directives for User-Generated Content (UGC) in their app stores.
15
17
 
16
18
  ## 👨‍💻 Example
@@ -64,6 +66,8 @@ That's the whole idea: **the messy, legally-loaded plumbing every social/UGC app
64
66
 
65
67
  ## Quickstart
66
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
+
67
71
  Add the gem:
68
72
 
69
73
  ```ruby
@@ -171,9 +175,18 @@ Post.where.not(user_id: Moderate.blocked_ids_for(current_user))
171
175
  ```ruby
172
176
  current_user.report!(@message, category: :harassment, details: "Won't stop messaging me")
173
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 %>
174
187
  ```
175
188
 
176
- `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.
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.
177
190
 
178
191
  ## 🚩 Reportable content
179
192
 
@@ -191,6 +204,37 @@ end
191
204
 
192
205
  *(Explicit-include equivalent: `include Moderate::Reportable` + `reportable_fields :title, :description`.)*
193
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
+
194
238
  You get:
195
239
 
196
240
  ```ruby
@@ -206,6 +250,14 @@ Drop a report link into any view with the helper (it renders nothing if the view
206
250
  <%= moderate_report_link(@listing, field: :description) %>
207
251
  ```
208
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
+
209
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.
210
262
 
211
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.
@@ -233,6 +285,8 @@ end
233
285
 
234
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.
235
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
+
236
290
  Check content directly anywhere:
237
291
 
238
292
  ```ruby
@@ -294,16 +348,22 @@ Moderate::Appeal.pending # appeals awaiting a human
294
348
 
295
349
  report.resolve!(by: admin, remove_content: true, ban_user: false, note: "Removed: hate speech")
296
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")
297
353
  appeal.uphold!(by: admin, note: "...") # overturns the decision
298
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
299
357
  ```
300
358
 
301
- 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.
302
362
 
303
363
  ### Use it from a controller (BYOUI)
304
364
 
305
365
  ```ruby
306
- class Admin::ReportsController < ApplicationController
366
+ class Admin::ReportsController < ApplicationController # the same concern drives Admin::FlagsController
307
367
  include Moderate::Moderation # resolve!/dismiss! actions, strong params, redirects
308
368
 
309
369
  before_action :require_admin
@@ -346,7 +406,9 @@ end
346
406
 
347
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).)
348
408
 
349
- The full event vocabulary: `report_received`, `report_decision`, `affected_user_decision`, `appeal_received`, `appeal_decision`, `user_blocked`, `user_unblocked`, `user_banned`, `content_flagged`, `content_removed`.
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`.
350
412
 
351
413
  ## ⚖️ DSA & app-store compliance, out of the box
352
414
 
@@ -356,6 +418,7 @@ The full event vocabulary: `report_received`, `report_decision`, `affected_user_
356
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.
357
419
  - **DSA Art. 20 (appeals):** a free, electronic internal complaint mechanism, open ≥ 6 months, decided by a human.
358
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).
359
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).
360
423
 
361
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.
@@ -371,7 +434,7 @@ The full event vocabulary: `report_received`, `report_decision`, `affected_user_
371
434
 
372
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).
373
436
 
374
- 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 7.1+ schema.
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.
375
438
 
376
439
  ## Configuration reference
377
440
 
@@ -384,7 +447,7 @@ Moderate.configure do |config|
384
447
  config.audit = ->(event) { ... } # optional; no-op by default
385
448
  config.notify = ->(event) { ... } # optional; no-op by default
386
449
  config.on_block = ->(blocker:, blocked:, at:) { ... } # optional
387
- 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)
388
451
 
389
452
  config.filter "Message", :body, with: :wordlist, mode: :flag
390
453
  end
@@ -392,12 +455,90 @@ end
392
455
 
393
456
  Reportable classes are auto-discovered from the `has_reportable_content` macro (or `include Moderate::Reportable`) — no manual registry.
394
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
+
395
536
  ## Upgrading from 0.x
396
537
 
397
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:
398
539
 
399
540
  ```ruby
400
- validates :body, moderate: true # still works — equivalent to `moderates :body, mode: :block`
541
+ validates :body, moderate: true # still works — the same check as `moderates :body, with: :wordlist, mode: :block`
401
542
  ```
402
543
 
403
544
  Everything else is new. There's no automated data migration (0.x stored nothing). See [`CHANGELOG.md`](CHANGELOG.md).
@@ -408,6 +549,7 @@ We use Minitest. Run the suite (a dummy Rails app under `test/dummy`, against SQ
408
549
 
409
550
  ```bash
410
551
  bundle exec rake test
552
+ bundle exec rubocop # CI expects zero offenses
411
553
  ```
412
554
 
413
555
  ## Development
data/Rakefile CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  begin
2
4
  require "bundler/setup"
3
5
  rescue LoadError
@@ -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 actions drive either resource —
30
- # the host just includes this concern in whichever controller.
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
- return redirect_to appeal_return_path, alert: t("moderate.appeals.not_found", default: "We couldn't find that moderation decision.") if @report.blank?
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
- return redirect_to appeal_return_path, alert: t("moderate.appeals.not_found", default: "We couldn't find that moderation decision.") if @report.blank?
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 7.2 shipped a first-class controller `rate_limit` macro; on 7.1 it
226
- # doesn't exist, so we fall back to a tiny `Rails.cache`-backed counter. We
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
- if respond_to?(:new_report_path)
91
- new_report_path(query)
92
- elsif respond_to?(:new_moderate_report_path)
93
- new_moderate_report_path(query)
94
- else
95
- # Last-resort conventional path so the helper is never a hard dependency on
96
- # a particular route name. The host wires the actual route (BYOUI).
97
- "/reports/new?#{query.to_query}"
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.on_load(:action_view) do
150
- include Moderate::EngineHelper
151
- end if defined?(ActiveSupport)
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
- translation_scope = local_assigns[:translation_scope]
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><%= translation_scope ? t("#{translation_scope}.#{key}", default: key.to_s.humanize) : key.to_s.humanize %></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], translation_scope: "moderation.legal_reasons" %>
33
- <%= render "summary_card", title: t("moderate.transparency.cards.actions_by_basis", default: "Actions by basis"), rows: @summary[:actions_by_basis], translation_scope: "moderation.resolution_bases" %>
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>