Module: Moderate::Reportable

Extended by:
ActiveSupport::Concern
Included in:
Actor
Defined in:
lib/moderate/models/concerns/reportable.rb

Overview

The contract a model opts into to become reportable — i.e. a piece of user-generated content (a comment, a listing, a profile) that another user (or a public DSA notice) can file a report against.

Plain Rails polymorphism answers “what row was reported?” via the ‘Moderate::Report#reportable` association. This concern answers the Trust & Safety questions that polymorphism *can’t*:

- which fields of the record may be reported (`reportable_fields`)
- who is *responsible* for the content (`reported_owner`) — the person a
  decision's statement of reasons must reach (DSA Art. 17) and who a ban
  would apply to
- what the moderation queue should *call* this item (`moderation_label`)
- what immutable evidence to snapshot at report time so it survives an edit
  or delete (`moderation_snapshot`)
- what a moderator may *remove* without nuking the whole record
  (`remove_reported_field!`)
- whether a given viewer is even allowed to report it (`report_visible_to?`)

WHY a concern and not just config: these answers are intrinsic to each model (only the Listing knows its title is reportable but its internal SKU isn’t), so they belong ON the model. Hosts override the handful of methods that need domain knowledge; everything else has a safe default.

Regulatory grounding for why a reportable contract has to exist at all:

- Apple App Store Review Guideline 1.2 requires a way to report UGC and a
  mechanism to remove offending content:
  https://developer.apple.com/app-store/review/guidelines/#user-generated-content
- Google Play UGC policy requires in-app reporting and ongoing moderation:
  https://support.google.com/googleplay/android-developer/answer/9876937
- EU DSA Art. 16 (notice & action) presupposes that reported items can be
  identified, snapshotted, and acted on:
  https://eur-lex.europa.eu/eli/reg/2022/2065/oj

The documented include form is ‘include Moderate::Reportable` + `reportable_fields :a, :b`; the `has_reportable_content :a, :b` macro is exact sugar.

Instance Method Summary collapse

Instance Method Details

#flagged?(field = nil) ⇒ Boolean

Has this record been flagged and not yet resolved/dismissed? Optionally pass a field (‘listing.flagged?(:description)`) for field-level UI.

Returns:

  • (Boolean)


214
215
216
# File 'lib/moderate/models/concerns/reportable.rb', line 214

def flagged?(field = nil)
  pending_moderation_flags(field).exists?
end

#moderation_admin_path(_routes) ⇒ Object

The admin/back-office path for this content, so the moderation queue can deep link a reviewer straight to the item under review.



246
247
248
# File 'lib/moderate/models/concerns/reportable.rb', line 246

def moderation_admin_path(_routes)
  nil
end

#moderation_flags(field = nil) ⇒ Object

All auto-filter/manual flags against this record, optionally narrowed to a field. We keep this as a method instead of a ‘has_many :flags` association: `flags` is a common host-model word, while `flagged?` is the public DX.



198
199
200
201
202
203
204
# File 'lib/moderate/models/concerns/reportable.rb', line 198

def moderation_flags(field = nil)
  moderation_scope_by_field(
    Moderate::Flag.where(flaggable: self),
    :field,
    field
  )
end

#moderation_labelObject

The human-readable name the moderation queue shows for this item. Defaults to Rails’ own ‘to_s` (usually “#<Comment id: 42>”); override for something an admin can act on at a glance, e.g. `def moderation_label = “Comment #id”`.



121
122
123
# File 'lib/moderate/models/concerns/reportable.rb', line 121

def moderation_label
  to_s
end

#moderation_return_path(_routes) ⇒ Object

Where to send a user *back to* after they file a report on this content (e.g. the content’s own page). Falls back to the app root when nil.



240
241
242
# File 'lib/moderate/models/concerns/reportable.rb', line 240

def moderation_return_path(_routes)
  nil
end

#moderation_snapshot(_field) ⇒ Object

The immutable evidence text to capture for ‘field` at report time, so the report survives the content being edited or deleted. The Report model calls this in its `before_validation :capture_snapshot` (DSA-grade evidence preservation). Defaults to nil; override to return the actual field text (e.g. `public_send(field)`) or a description of a non-text attachment.

NAMED ‘moderation_snapshot` per the gem’s public reportable contract; takes the field so a multi-field record can snapshot the specific thing reported.



133
134
135
# File 'lib/moderate/models/concerns/reportable.rb', line 133

def moderation_snapshot(_field)
  nil
end

#moderation_subject_url(_routes) ⇒ Object

The public, canonical URL of this content — what a DSA Art. 16 notice records as the precise location of the allegedly illegal content, and what a statement of reasons points the affected user to.



234
235
236
# File 'lib/moderate/models/concerns/reportable.rb', line 234

def moderation_subject_url(_routes)
  nil
end

#open_reports(field = nil) ⇒ Object

Open reports filed against this record, optionally narrowed to one field. Hosts can use this when they need the actual relation (queue previews, counters, “already reported” affordances) instead of just a boolean.



185
186
187
# File 'lib/moderate/models/concerns/reportable.rb', line 185

def open_reports(field = nil)
  moderation_scope_by_field(reports.open, :reported_field, field)
end

#pending_moderation_flags(field = nil) ⇒ Object

Pending flags are the “allowed through, awaiting review” state a host may want to surface near user-generated content.



208
209
210
# File 'lib/moderate/models/concerns/reportable.rb', line 208

def pending_moderation_flags(field = nil)
  moderation_flags(field).pending
end

#removable_reported_field?(_field) ⇒ Boolean

Companion query to ‘remove_reported_field!`: CAN this specific `field` be removed on this record? An admin UI uses it to decide whether to OFFER a “remove content” action at all. Without it, a host that only removes SOME fields (e.g. an avatar but not a display name) would render a remove button that always fails when the moderator clicks it on a non-removable field.

Defaults to false (mirrors the no-op ‘remove_reported_field!`). Override it alongside `remove_reported_field!` and have the latter reuse it, so the “can I?” answer and the “do it” action never drift apart.

Returns:

  • (Boolean)


158
159
160
# File 'lib/moderate/models/concerns/reportable.rb', line 158

def removable_reported_field?(_field)
  false
end

#remove_reported_field!(_field) ⇒ Object

Remove the reported ‘field` as an enforcement action — WITHOUT destroying the whole record (a moderator removing one objectionable photo shouldn’t delete the whole listing). Returns truthy if something was actually removed (so the decision service knows whether to fire the ‘content_removed` event).

Defaults to a no-op returning false: a model that hasn’t opted into field-level removal simply reports “nothing removed,” and the moderator falls back to other actions. Override to purge an attachment, blank a column, etc.



145
146
147
# File 'lib/moderate/models/concerns/reportable.rb', line 145

def remove_reported_field!(_field)
  false
end

#report_visible_to?(viewer, field:) ⇒ Boolean

Visibility/authorization gate for the report affordance: should ‘viewer` be offered a “report this” control for `field`? The default enforces two rules:

1. the field must be reportable (`reportable_field_allowed?`), and
2. you can't report YOUR OWN content — the affordance is hidden from the
   content's owner, so an author never sees "Report" on their own post. We
   compare the viewer to this content's `reported_owner` (the Reportable
   contract's "who is responsible" answer).

The ‘moderate_report_link` helper renders nothing when this is false, and the report controller redirects. Hosts can override for richer rules. (A User with `has_reporting_and_blocking` overrides this in Moderate::Actor to compare ids directly, since a user IS its own owner.)

Returns:

  • (Boolean)


175
176
177
178
179
180
# File 'lib/moderate/models/concerns/reportable.rb', line 175

def report_visible_to?(viewer, field:)
  return false unless reportable_field_allowed?(field)
  return false if viewer.present? && moderation_owner_is?(viewer)

  true
end

#reportable_field_allowed?(field) ⇒ Boolean

Is ‘field` one a reporter is allowed to name? Two cases:

- A BLANK field is ALWAYS allowed — it means "report the whole record," which
  is valid whether or not the model declared specific reportable fields. (A
  user tapping "Report this comment" doesn't name a field; only the public DSA
  notice / a field-targeted in-app flow does.) So a Comment that declares
  `has_reportable_content :body` can still be reported as a whole with a nil field.

- A NAMED field must be in the whitelist. With no fields declared, the
  whitelist is empty, so any named field is rejected (there's nothing to
  target field-by-field on a bare-`has_reportable_content` record).

This is the authorization gate the Report model and the report controller both consult before accepting a ‘reported_field`.

Returns:

  • (Boolean)


96
97
98
99
100
101
# File 'lib/moderate/models/concerns/reportable.rb', line 96

def reportable_field_allowed?(field)
  field_s = field.to_s
  return true if field_s.empty?

  self.class.reportable_fields.include?(field_s)
end

#reported?(field = nil) ⇒ Boolean

Has this record received any open reports? This is the public predicate documented beside ‘reports` in the README.

Returns:

  • (Boolean)


191
192
193
# File 'lib/moderate/models/concerns/reportable.rb', line 191

def reported?(field = nil)
  open_reports(field).exists?
end

#reported_ownerObject

WHO is responsible for this content — the account a decision’s statement of reasons reaches (DSA Art. 17) and the user a ban would apply to.

NO default: a model that can be reported MUST tell the gem who’s behind it, because guessing wrong here means notifying or banning the wrong person. We raise a NotImplementedError naming the class so the omission is loud at the first report, not silent. (A ‘User` model with `has_reporting_and_blocking` is itself reportable and returns `self` — see Moderate::Actor.)

Raises:

  • (NotImplementedError)


111
112
113
114
115
116
# File 'lib/moderate/models/concerns/reportable.rb', line 111

def reported_owner
  raise NotImplementedError,
    "#{self.class.name} is reportable but doesn't define #reported_owner. " \
    "Return the user responsible for this content (the one a decision notifies " \
    "and a ban applies to), e.g. `def reported_owner = user`."
end