Class: Moderate::Report
- Inherits:
-
ApplicationRecord
- Object
- ActiveRecord::Base
- ApplicationRecord
- Moderate::Report
- Defined in:
- lib/moderate/models/report.rb
Overview
The core notice/report record of the Trust & Safety system.
A ‘Moderate::Report` is BOTH an in-app community report (“this comment is harassment”) AND a public EU DSA legal notice (“this URL hosts illegal content”), distinguished by `intake_kind`. Keeping them in one table — one queue, one decision workflow, one evidence snapshot — is deliberate: the DSA wants notices “decided in a timely, diligent, non-arbitrary and objective manner” (Art. 16(6)), and the simplest way to guarantee that is to make a legal notice land in the exact same `pending` queue a moderator already works. See: eur-lex.europa.eu/eli/reg/2022/2065/oj
The model owns: validations, the immutable evidence snapshot, the automated-processing disclosure (DSA Art. 16(6)/17(3)©), the appeal window (DSA Art. 20), and a signed-GlobalID locator for the public flows. It stays host-agnostic: who “owns” reported content, how content is snapshotted, and what fields may be reported are all answered by the polymorphic ‘has_reportable_content` (through the `Moderate::Reportable` interface), never by anything domain-specific.
Constant Summary collapse
- STATUSES =
%w[open actioned dismissed].freeze
- INTAKE_KINDS =
%w[community dsa].freeze
- DEFAULT_CATEGORIES =
The default in-app COMMUNITY report categories. Two taxonomies on purpose (README): a friendly community set the user picks from a “Report” sheet, plus the regulator-aligned DSA legal-reason set below for public notices. These two vocabularies serve different audiences and must NOT be collapsed.
This list is HOST-CUSTOMIZABLE: a host that needs its own community labels sets ‘config.report_categories = %w` and validation tracks that instead (see `.report_categories` below). The taxonomy lives in the MODEL — there is no DB check constraint on `category` — precisely so adding a label never requires a migration. (The DSA legal-reason/country lists below are regulator-defined and therefore fixed, NOT host-overridable.)
%w[ harassment hate threats sexual_content spam fraud unsafe_behavior illegal_content privacy child_safety other hate_abuse_harassment violent_speech graphic_violent_media illegal_regulated_behaviors impersonation adult_sexual_content private_non_consensual_content suicide_self_harm terrorism_violent_extremism scam_fraud ].freeze
- DSA_LEGAL_REASONS =
The DSA “statement of reasons” legal-reason taxonomy used on public notices. These are the categories the EU Transparency Database expects, so the Art. 24 transparency counters and the Art. 16 intake speak one regulator-aligned vocabulary. Regulator-defined, so this is a FIXED constant (not host-overridable): widening it is a gem change, not a host config. Validated by the model, not the DB.
%w[ animal_welfare consumer_information cyber_violence data_protection_privacy illegal_or_harmful_speech civic_elections non_consensual_behavior pornography_sexualized_content protection_of_minors public_security scams_fraud scope_of_platform_service self_harm unsafe_illegal_products violence intellectual_property other ].freeze
- EU_COUNTRY_CODES =
The 27 EU member-state codes plus “EU” (whole-union). The DSA notice form requires the member state whose law is allegedly broken. Regulator-defined and therefore fixed (not host-overridable). Validated by the model, not the DB.
%w[ AT BE BG CY CZ DE DK EE ES FI FR GR HR HU IE IT LT LU LV MT NL PL PT RO SE SI SK EU ].freeze
- CONTENT_TYPES =
Generic, host-agnostic content-type buckets. A host’s reportable supplies its own via ‘moderation_content_type`, but the stored value is constrained to this vocabulary (model-level inclusion, NULL allowed) so the queue and transparency counts stay tidy.
%w[ user_profile profile_avatar listing message conversation group other ].freeze
- RESOLUTION_BASES =
The legal/contractual ground a moderator records when closing a report —the DSA Art. 17(1) “legal or contractual ground” of the statement of reasons. Model-level inclusion (NULL allowed); no DB constraint.
%w[terms law law_and_terms insufficient_information no_violation].freeze
- MESSAGE_MAX_LENGTH =
Matches the ‘moderate_reports_message_length_check` DB constraint — the one value guard kept at the DB level (a cheap runaway-free-text backstop).
4000- SIGNED_GLOBAL_ID_PURPOSE =
Signed-GlobalID purposes. A purpose is a namespace tag baked into the signed token so a token minted to locate the *reported content* can’t be replayed to locate the *report itself* (and vice versa) — GlobalID verifies the purpose on the way back out. See github.com/rails/globalid#signed-global-ids. The strings are gem-stable (“moderate_*”) and host-agnostic.
"moderate_report"- APPEAL_SIGNED_GLOBAL_ID_PURPOSE =
"moderate_appeal"- APPEAL_WINDOW =
DSA Art. 20(1): the internal complaint (appeal) mechanism must stay open for “at least six months following the decision”. We default to exactly six months and refuse appeals filed after it (enforced on Moderate::Appeal).
6.months
Instance Attribute Summary collapse
-
#block_reported_user ⇒ Object
Transient flags a caller may set on the intake side (e.g. “don’t email a receipt for this seeded record”, “also block the reported user”).
-
#skip_received_notice ⇒ Object
Transient flags a caller may set on the intake side (e.g. “don’t email a receipt for this seeded record”, “also block the reported user”).
Class Method Summary collapse
-
.locate_signed_appeal_report(token) ⇒ Object
Resolve a signed token back into the Report it was minted for (used by the public appeal flow, where the appellant arrives via an emailed signed link).
-
.locate_signed_reportable(token) ⇒ Object
Resolve a signed token back into the reported content.
-
.report_categories ⇒ Object
The community ‘category` vocabulary in effect: the host’s ‘config.report_categories` if they set one, else the gem’s DEFAULT_CATEGORIES.
Instance Method Summary collapse
-
#acknowledge!(at: Time.current) ⇒ Object
DSA Art.
- #actioned? ⇒ Boolean
-
#anonymous_notice? ⇒ Boolean
The anonymity carve-out from DSA Art.
-
#automated_processing_used? ⇒ Boolean
DSA Art.
-
#close_redress_window!(at: resolved_at || Time.current) ⇒ Object
DSA Art.
- #closed? ⇒ Boolean
- #community? ⇒ Boolean
-
#dismiss!(by:, note:) ⇒ Object
Dismiss this report (no action taken) — sugar over Moderate::Services::ResolveReport#dismiss!.
- #dismissed? ⇒ Boolean
- #dsa? ⇒ Boolean
-
#notifier_label ⇒ Object
How to address the notifier in copy: their name if given, else their email.
-
#open? ⇒ Boolean
— Status predicates —————————————————-.
-
#reportable_label ⇒ Object
A label for the reported thing.
-
#reported_content_text ⇒ Object
The snapshotted text of the reported field, asked of the reportable.
-
#resolve!(by:, **options) ⇒ Object
Resolve this report — model-level sugar over Moderate::Services::ResolveReport, so a host can write ‘report.resolve!(by: moderator, remove_content: true, ban_user: true, note: “…”)` instead of constructing the service.
-
#safe_subject_url ⇒ Object
— URL helpers (defensive parsing) ————————————-.
- #safe_subject_url_for(url) ⇒ Object
- #safe_subject_urls ⇒ Object
- #signed_appeal_gid ⇒ Object
-
#signed_reportable_gid ⇒ Object
— Signed GIDs for emailed links —————————————.
-
#subject_url_list ⇒ Object
The de-duplicated list of notice URLs (a notice may cite several).
Instance Attribute Details
#block_reported_user ⇒ Object
Transient flags a caller may set on the intake side (e.g. “don’t email a receipt for this seeded record”, “also block the reported user”). They never persist; the services read them. Kept here so both the model and its services share one definition.
101 102 103 |
# File 'lib/moderate/models/report.rb', line 101 def block_reported_user @block_reported_user end |
#skip_received_notice ⇒ Object
Transient flags a caller may set on the intake side (e.g. “don’t email a receipt for this seeded record”, “also block the reported user”). They never persist; the services read them. Kept here so both the model and its services share one definition.
101 102 103 |
# File 'lib/moderate/models/report.rb', line 101 def skip_received_notice @skip_received_notice end |
Class Method Details
.locate_signed_appeal_report(token) ⇒ Object
Resolve a signed token back into the Report it was minted for (used by the public appeal flow, where the appellant arrives via an emailed signed link). Locked to ‘self` so the token can only ever name a Report.
228 229 230 231 232 |
# File 'lib/moderate/models/report.rb', line 228 def self.locate_signed_appeal_report(token) return if token.blank? GlobalID::Locator.locate_signed(token, for: APPEAL_SIGNED_GLOBAL_ID_PURPOSE, only: [self]) end |
.locate_signed_reportable(token) ⇒ Object
Resolve a signed token back into the reported content. Prefer the auto-discovered registry allow-list when it is already populated, then fall back to the reportable contract after verifying the SignedGlobalID purpose. That second path matters in lazy-loaded Rails apps: resolving the token may be the first thing that constantizes the reportable model, so the registry can be stale until after GlobalID loads the class.
218 219 220 221 222 223 |
# File 'lib/moderate/models/report.rb', line 218 def self.locate_signed_reportable(token) return if token.blank? locate_signed_reportable_from_registry(token) || locate_signed_reportable_by_contract(token) end |
.report_categories ⇒ Object
The community ‘category` vocabulary in effect: the host’s ‘config.report_categories` if they set one, else the gem’s DEFAULT_CATEGORIES. Read at the point of use (not memoized) so a host can change it without a reboot and so it tracks ‘Moderate.reset!` in tests. Coerced to strings to match the normalized, persisted column value.
206 207 208 |
# File 'lib/moderate/models/report.rb', line 206 def self.report_categories Array(Moderate.config.report_categories).map(&:to_s).presence || DEFAULT_CATEGORIES end |
Instance Method Details
#acknowledge!(at: Time.current) ⇒ Object
DSA Art. 16(4): record that the confirmation of receipt was acknowledged. ‘update_column` skips validations/callbacks on purpose — this is a timestamp stamp, not a content change, and must succeed even on an already-validated row. Idempotent: only sets the first acknowledgement.
329 330 331 |
# File 'lib/moderate/models/report.rb', line 329 def acknowledge!(at: Time.current) update_column(:acknowledged_at, at) if acknowledged_at.blank? end |
#actioned? ⇒ Boolean
240 241 242 |
# File 'lib/moderate/models/report.rb', line 240 def actioned? status == "actioned" end |
#anonymous_notice? ⇒ Boolean
The anonymity carve-out from DSA Art. 16(2)©: a notice may omit the notifier’s identity ONLY when it concerns offences against minors. We tie it to the ‘protection_of_minors` legal reason — the single ground for which the regulation lets a notice be filed anonymously.
264 265 266 |
# File 'lib/moderate/models/report.rb', line 264 def anonymous_notice? anonymous? && legal_reason == "protection_of_minors" end |
#automated_processing_used? ⇒ Boolean
DSA Art. 17(3)©: did automated means (a wordlist/image/remote classifier) participate in surfacing or deciding this report? The decision email reads this to truthfully state whether automation was used. We treat the disclosure as “used” if the explicit ‘used` flag is true OR any captured evidence value is true — defensive against partially-populated evidence hashes.
362 363 364 365 366 367 368 369 |
# File 'lib/moderate/models/report.rb', line 362 def automated_processing_used? automation = automated_processing.to_h boolean = ActiveModel::Type::Boolean.new return true if boolean.cast(automation["used"]) automation.except("used").values.any? { |value| value == true || value == "true" } end |
#close_redress_window!(at: resolved_at || Time.current) ⇒ Object
DSA Art. 20(1): open the redress (appeal) window for at least six months after the decision. Stamped once when the report is decided; idempotent so re-running a decision flow doesn’t slide the deadline.
336 337 338 |
# File 'lib/moderate/models/report.rb', line 336 def close_redress_window!(at: resolved_at || Time.current) update_column(:appeal_deadline_at, at + APPEAL_WINDOW) if appeal_deadline_at.blank? end |
#closed? ⇒ Boolean
248 249 250 |
# File 'lib/moderate/models/report.rb', line 248 def closed? actioned? || dismissed? end |
#community? ⇒ Boolean
256 257 258 |
# File 'lib/moderate/models/report.rb', line 256 def community? intake_kind == "community" end |
#dismiss!(by:, note:) ⇒ Object
Dismiss this report (no action taken) — sugar over Moderate::Services::ResolveReport#dismiss!.
353 354 355 |
# File 'lib/moderate/models/report.rb', line 353 def dismiss!(by:, note:) Moderate::Services::ResolveReport.new(self, by: by).dismiss!(note: note) end |
#dismissed? ⇒ Boolean
244 245 246 |
# File 'lib/moderate/models/report.rb', line 244 def dismissed? status == "dismissed" end |
#dsa? ⇒ Boolean
252 253 254 |
# File 'lib/moderate/models/report.rb', line 252 def dsa? intake_kind == "dsa" end |
#notifier_label ⇒ Object
How to address the notifier in copy: their name if given, else their email.
271 272 273 |
# File 'lib/moderate/models/report.rb', line 271 def notifier_label notifier_name.presence || notifier_email end |
#open? ⇒ Boolean
— Status predicates —————————————————-
236 237 238 |
# File 'lib/moderate/models/report.rb', line 236 def open? status == "open" end |
#reportable_label ⇒ Object
A label for the reported thing. We ask the reportable for its own label (via the Moderate::Reportable interface), fall back to the notice URL, and finally to a generic localized “legal notice” string — so a notice about an external URL (no in-app record) still renders something sensible in the queue.
279 280 281 282 283 |
# File 'lib/moderate/models/report.rb', line 279 def reportable_label return reportable.moderation_label if reportable.respond_to?(:moderation_label) subject_url.presence || I18n.t("moderate.reports.legal_notice_label", default: "Legal notice") end |
#reported_content_text ⇒ Object
The snapshotted text of the reported field, asked of the reportable. Returns nil when the reportable doesn’t expose snapshot text (or there’s no record).
287 288 289 290 291 |
# File 'lib/moderate/models/report.rb', line 287 def reported_content_text return unless reportable.respond_to?(:moderation_snapshot) reportable.moderation_snapshot(reported_field) end |
#resolve!(by:, **options) ⇒ Object
Resolve this report — model-level sugar over Moderate::Services::ResolveReport, so a host can write ‘report.resolve!(by: moderator, remove_content: true, ban_user: true, note: “…”)` instead of constructing the service. The service is where the real work lives (row lock + re-check open, in-transaction enforcement, out-of-transaction notify, statement-of-reasons + appeal-window stamping); this only forwards. `by:` is the moderator; the rest (`note:`, `remove_content:`, `ban_user:`, `resolution_basis:`) pass straight through.
347 348 349 |
# File 'lib/moderate/models/report.rb', line 347 def resolve!(by:, **) Moderate::Services::ResolveReport.new(self, by: by).resolve!(**) end |
#safe_subject_url ⇒ Object
— URL helpers (defensive parsing) ————————————-
305 306 307 |
# File 'lib/moderate/models/report.rb', line 305 def safe_subject_url parsed_subject_uri(subject_url)&.to_s end |
#safe_subject_url_for(url) ⇒ Object
309 310 311 |
# File 'lib/moderate/models/report.rb', line 309 def safe_subject_url_for(url) parsed_subject_uri(url)&.to_s end |
#safe_subject_urls ⇒ Object
313 314 315 |
# File 'lib/moderate/models/report.rb', line 313 def safe_subject_urls subject_url_list.filter_map { |url| parsed_subject_uri(url)&.to_s } end |
#signed_appeal_gid ⇒ Object
299 300 301 |
# File 'lib/moderate/models/report.rb', line 299 def signed_appeal_gid to_sgid_param(for: APPEAL_SIGNED_GLOBAL_ID_PURPOSE) end |
#signed_reportable_gid ⇒ Object
— Signed GIDs for emailed links —————————————
295 296 297 |
# File 'lib/moderate/models/report.rb', line 295 def signed_reportable_gid reportable&.to_sgid_param(for: SIGNED_GLOBAL_ID_PURPOSE) end |
#subject_url_list ⇒ Object
The de-duplicated list of notice URLs (a notice may cite several). Falls back to the single ‘subject_url` for records created before/without the list column.
319 320 321 |
# File 'lib/moderate/models/report.rb', line 319 def subject_url_list Array(subject_urls).presence || Array(subject_url) end |