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
-
#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. -
#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.
-
#moderation_flags(field = nil) ⇒ Object
All auto-filter/manual flags against this record, optionally narrowed to a field.
-
#moderation_label ⇒ Object
The human-readable name the moderation queue shows for this item.
-
#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).
-
#moderation_snapshot(_field) ⇒ Object
The immutable evidence text to capture for
fieldat report time, so the report survives the content being edited or deleted. -
#moderation_subject_url(_routes) ⇒ Object
The public, canonical URL of this content — what a DSA Art.
-
#open_reports(field = nil) ⇒ Object
Open reports filed against this record, optionally narrowed to one field.
-
#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.
-
#removable_reported_field?(_field) ⇒ Boolean
Companion query to
remove_reported_field!: CAN this specificfieldbe removed on this record? An admin UI uses it to decide whether to OFFER a "remove content" action at all. -
#remove_reported_field!(_field) ⇒ Object
Remove the reported
fieldas an enforcement action — WITHOUT destroying the whole record (a moderator removing one objectionable photo shouldn't delete the whole listing). -
#report_visible_to?(viewer, field:) ⇒ Boolean
Visibility/authorization gate for the report affordance: should
viewerbe offered a "report this" control forfield? The default enforces two rules:. -
#reportable_field_allowed?(field) ⇒ Boolean
Is
fieldone a reporter is allowed to name? Two cases:. -
#reported?(field = nil) ⇒ Boolean
Has this record received any open reports? This is the public predicate documented beside
reportsin the README. -
#reported_owner ⇒ Object
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.
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.
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_label ⇒ Object
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.
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.)
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.
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.
191 192 193 |
# File 'lib/moderate/models/concerns/reportable.rb', line 191 def reported?(field = nil) open_reports(field).exists? end |
#reported_owner ⇒ Object
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.)
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 |