Class: Moderate::Report

Inherits:
ApplicationRecord show all
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
%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

Class Method Summary collapse

Instance Method Summary collapse

Instance Attribute Details

#block_reported_userObject

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_noticeObject

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_categoriesObject

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

Returns:

  • (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.

Returns:

  • (Boolean)


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.

Returns:

  • (Boolean)


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

Returns:

  • (Boolean)


248
249
250
# File 'lib/moderate/models/report.rb', line 248

def closed?
  actioned? || dismissed?
end

#community?Boolean

Returns:

  • (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

Returns:

  • (Boolean)


244
245
246
# File 'lib/moderate/models/report.rb', line 244

def dismissed?
  status == "dismissed"
end

#dsa?Boolean

Returns:

  • (Boolean)


252
253
254
# File 'lib/moderate/models/report.rb', line 252

def dsa?
  intake_kind == "dsa"
end

#notifier_labelObject

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 —————————————————-

Returns:

  • (Boolean)


236
237
238
# File 'lib/moderate/models/report.rb', line 236

def open?
  status == "open"
end

#reportable_labelObject

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_textObject

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:, **options)
  Moderate::Services::ResolveReport.new(self, by: by).resolve!(**options)
end

#safe_subject_urlObject

— 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_urlsObject



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_gidObject



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_gidObject

— 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_listObject

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