moderate 0.1.0 → 1.0.0.beta2

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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +8 -0
  3. data/.simplecov +62 -0
  4. data/AGENTS.md +7 -0
  5. data/Appraisals +16 -0
  6. data/CHANGELOG.md +109 -1
  7. data/CLAUDE.md +7 -0
  8. data/README.md +378 -29
  9. data/Rakefile +28 -2
  10. data/app/controllers/concerns/moderate/moderation.rb +161 -0
  11. data/app/controllers/moderate/appeals_controller.rb +190 -0
  12. data/app/controllers/moderate/application_controller.rb +45 -0
  13. data/app/controllers/moderate/notices_controller.rb +382 -0
  14. data/app/controllers/moderate/transparency_reports_controller.rb +30 -0
  15. data/app/helpers/moderate/engine_helper.rb +151 -0
  16. data/app/views/moderate/appeals/new.html.erb +78 -0
  17. data/app/views/moderate/notices/new.html.erb +255 -0
  18. data/app/views/moderate/transparency_reports/_summary_card.html.erb +20 -0
  19. data/app/views/moderate/transparency_reports/show.html.erb +52 -0
  20. data/config/moderate/blocklists/en.yml +81 -0
  21. data/config/moderate/blocklists/es.yml +40 -0
  22. data/config/routes.rb +36 -0
  23. data/context7.json +4 -0
  24. data/docs/compliance.md +178 -0
  25. data/docs/configuration.md +326 -0
  26. data/docs/dsa-notice-form.md +371 -0
  27. data/docs/images/moderate-user-report-block-actions.webp +0 -0
  28. data/docs/madmin.md +490 -0
  29. data/docs/notifications.md +363 -0
  30. data/examples/aws_rekognition_adapter.rb +140 -0
  31. data/examples/openai_moderation_adapter.rb +111 -0
  32. data/gemfiles/rails_7.1.gemfile +36 -0
  33. data/gemfiles/rails_7.2.gemfile +36 -0
  34. data/gemfiles/rails_8.1.gemfile +36 -0
  35. data/lib/generators/moderate/install_generator.rb +56 -0
  36. data/lib/generators/moderate/templates/create_moderate_tables.rb.erb +237 -0
  37. data/lib/generators/moderate/templates/initializer.rb +198 -0
  38. data/lib/generators/moderate/views_generator.rb +63 -0
  39. data/lib/moderate/configuration.rb +355 -0
  40. data/lib/moderate/engine.rb +138 -0
  41. data/lib/moderate/errors.rb +26 -0
  42. data/lib/moderate/event.rb +75 -0
  43. data/lib/moderate/filters/base.rb +126 -0
  44. data/lib/moderate/filters/wordlist.rb +255 -0
  45. data/lib/moderate/jobs/classify_job.rb +162 -0
  46. data/lib/moderate/label.rb +111 -0
  47. data/lib/moderate/macros.rb +90 -0
  48. data/lib/moderate/models/appeal.rb +154 -0
  49. data/lib/moderate/models/application_record.rb +31 -0
  50. data/lib/moderate/models/block.rb +203 -0
  51. data/lib/moderate/models/concerns/actor.rb +174 -0
  52. data/lib/moderate/models/concerns/content_filterable.rb +214 -0
  53. data/lib/moderate/models/concerns/reportable.rb +282 -0
  54. data/lib/moderate/models/flag.rb +169 -0
  55. data/lib/moderate/models/report.rb +620 -0
  56. data/lib/moderate/result.rb +176 -0
  57. data/lib/moderate/services/intake_appeal.rb +89 -0
  58. data/lib/moderate/services/intake_notice.rb +132 -0
  59. data/lib/moderate/services/intake_report.rb +132 -0
  60. data/lib/moderate/services/resolve_appeal.rb +134 -0
  61. data/lib/moderate/services/resolve_flag.rb +101 -0
  62. data/lib/moderate/services/resolve_report.rb +291 -0
  63. data/lib/moderate/version.rb +1 -1
  64. data/lib/moderate.rb +365 -18
  65. data/log/development.log +0 -0
  66. data/log/test.log +0 -0
  67. metadata +156 -15
@@ -0,0 +1,214 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moderate
4
+ # Pre-publication content filtering for a model's fields. Backs the `moderates`
5
+ # macro (and its documented equivalent, `include Moderate::ContentFilterable` +
6
+ # `moderates_fields :body`).
7
+ #
8
+ # For each declared field the concern resolves the field's FilterPolicy (via
9
+ # `Moderate.filter_policy_for`, which walks the ancestor chain so an STI parent's
10
+ # policy covers its children) and enforces it in one of two ways, by mode:
11
+ #
12
+ # :off — nothing.
13
+ # :block — a VALIDATION. If the classifier flags the value, the save is
14
+ # rejected with `errors.add(field, :objectionable_content)`. Runs
15
+ # synchronously, so it only works with a synchronous adapter — the
16
+ # Configuration validates that invariant (README: ":block requires a
17
+ # synchronous adapter").
18
+ # :flag — an AFTER_COMMIT side effect. The save SUCCEEDS, then (only if the
19
+ # field actually changed) the value is classified and a
20
+ # `Moderate::Flag` is filed when it trips. HOW it's classified
21
+ # depends on the adapter: a synchronous adapter (the wordlist)
22
+ # classifies inline right here; an ASYNC adapter (`synchronous? ==
23
+ # false` — any remote moderation API) is routed through
24
+ # `Moderate::ClassifyJob`, because blocking network I/O must never
25
+ # run inside the request that saved the content. The job re-reads
26
+ # the current value and files the Flag itself.
27
+ #
28
+ # WHY :flag lives in after_commit and not in a validator (this is the whole
29
+ # reason `:flag` is a `moderates` mode you can't hand-roll with `validates`):
30
+ # validators must be side-effect-free, and a Flag created inside a transaction
31
+ # that later rolls back would silently vanish — you'd think you flagged something
32
+ # you didn't. `after_commit` guarantees the surrounding transaction committed
33
+ # before we write the Flag (and that a ClassifyJob never races a rollback). See
34
+ # docs/configuration.md ("`:flag` never lives in a validator").
35
+ #
36
+ # ACTIVE STORAGE ATTACHMENTS work out of the box: `moderates :avatar, with:
37
+ # :your_image_adapter, mode: :flag` on a `has_one_attached :avatar` model needs
38
+ # no extra wiring. AR dirty tracking can't see attachment writes — and Active
39
+ # Storage clears `attachment_changes` before after_commit — so the concern
40
+ # snapshots "these filtered attachments changed" in a before_save and consumes
41
+ # the snapshot at commit time. The overridable seam below still exists for
42
+ # richer cases (derived values, non-AS blobs).
43
+ module ContentFilterable
44
+ extend ActiveSupport::Concern
45
+
46
+ included do
47
+ # The set of filtered field names (Strings), inherited via class_attribute so
48
+ # STI subclasses keep their parent's filtered fields. Accumulates across
49
+ # multiple `moderates`/`moderates_fields` declarations on the same class.
50
+ class_attribute :moderation_filtered_fields, instance_writer: false, default: [].freeze
51
+
52
+ validate :moderate_blocked_fields_must_be_allowed
53
+ # Snapshot attachment writes BEFORE Active Storage's own save callbacks
54
+ # clear `attachment_changes` — by after_commit they're gone (see
55
+ # activestorage's Attached::Model). One-shot; consumed + cleared below.
56
+ before_save :moderate_snapshot_attachment_changes
57
+ after_commit :moderate_flag_filtered_fields
58
+ end
59
+
60
+ class_methods do
61
+ # Register one or more fields for filtering. Additive and de-duplicated, so
62
+ # `moderates :a; moderates :b` and `moderates_fields :a, :b` are equivalent.
63
+ # The PER-FIELD adapter/mode (the `with:`/`mode:` of the `moderates` macro)
64
+ # is recorded separately as a Configuration FilterPolicy by the macro; this
65
+ # method only tracks WHICH fields to run on commit/validate.
66
+ def moderates_fields(*fields)
67
+ self.moderation_filtered_fields =
68
+ (moderation_filtered_fields + fields.map(&:to_s)).uniq.freeze
69
+ end
70
+ end
71
+
72
+ private
73
+
74
+ # :block enforcement — a validation. We deliberately skip :flag fields here
75
+ # (their work happens after_commit) and blank values (nothing to classify).
76
+ def moderate_blocked_fields_must_be_allowed
77
+ moderation_filtered_fields.each do |field|
78
+ policy = Moderate.filter_policy_for(self, field)
79
+ next unless policy.block?
80
+
81
+ value = moderation_field_value(field)
82
+ next if moderation_value_blank?(value)
83
+
84
+ result = Moderate.classify(value, policy: policy)
85
+ errors.add(field, :objectionable_content) if result.flagged?
86
+ end
87
+ end
88
+
89
+ # :flag enforcement — an after_commit side effect that files a Moderate::Flag
90
+ # (inline for synchronous adapters; via Moderate::ClassifyJob for async ones).
91
+ #
92
+ # We only act when the field actually CHANGED on this commit (re-saving an
93
+ # untouched record must not re-flag it and spam the queue), and we wrap each
94
+ # field in `begin/ensure` so the clean-up hook (`moderation_field_committed`)
95
+ # always runs even if classification raises — that hook is what clears the
96
+ # one-shot attachment snapshot.
97
+ def moderate_flag_filtered_fields
98
+ moderation_filtered_fields.each do |field|
99
+ policy = Moderate.filter_policy_for(self, field)
100
+ next unless policy.flag?
101
+ next unless moderation_field_changed_for_commit?(field)
102
+
103
+ begin
104
+ # ASYNC adapters (remote moderation APIs) never classify inline —
105
+ # a network call in the request's after_commit would stall the
106
+ # response for as long as the provider takes. ClassifyJob re-reads
107
+ # the value at run time (so it always classifies what's actually
108
+ # persisted) and files the Flag through the same Flag.flag! builder.
109
+ if Moderate.config.adapter_async?(policy.adapter)
110
+ Moderate::ClassifyJob.perform_later(self, field)
111
+ next
112
+ end
113
+
114
+ value = moderation_field_value(field)
115
+ next if moderation_value_blank?(value)
116
+
117
+ result = Moderate.classify(value, policy: policy)
118
+ next unless result.flagged?
119
+
120
+ Moderate::Flag.flag!(
121
+ flaggable: self,
122
+ field: field,
123
+ # `reported_owner` comes from Moderate::Reportable. A filterable model
124
+ # is usually also reportable; if it isn't, the owner is simply nil
125
+ # (the flag still lands in the queue, just unattributed).
126
+ owner: (reported_owner if respond_to?(:reported_owner)),
127
+ source: result.source,
128
+ mode: policy.mode,
129
+ # Cap the stored excerpt so we never balloon a row with a huge body;
130
+ # 500 chars is plenty of context for a reviewer.
131
+ excerpt: value.to_s.truncate(500),
132
+ categories: result.categories,
133
+ scores: result.scores,
134
+ # Keep the policy that produced the flag alongside the adapter's raw
135
+ # payload, so the queue can show "flagged by <adapter> under
136
+ # <Class>#<field>" and an auditor can inspect the untouched response.
137
+ context: {
138
+ raw: result.raw,
139
+ policy: { class_name: policy.class_name, field: policy.field, mode: policy.mode.to_s }
140
+ }
141
+ )
142
+ ensure
143
+ moderation_field_committed(field)
144
+ end
145
+ end
146
+ end
147
+
148
+ # --- Overridable field seam -----------------------------------------------
149
+ #
150
+ # These three methods are the seam that lets one concern filter BOTH plain text
151
+ # columns AND non-column content, without hosts having to re-plumb the common
152
+ # cases. Defaults handle text attributes AND Active Storage attachments; a
153
+ # host overrides them for anything richer (derived values, external blobs).
154
+
155
+ # The value to classify for `field`. Default: the attribute reader — which for
156
+ # a `has_one_attached` field returns the `ActiveStorage::Attached` proxy, the
157
+ # natural input for an image adapter (it can read `.record`, `.blob`,
158
+ # `.variant(...)`, or download bytes as it sees fit).
159
+ def moderation_field_value(field)
160
+ public_send(field)
161
+ end
162
+
163
+ # Did `field` change on the just-committed save? Defaults, in order:
164
+ # 1. the attachment snapshot taken in before_save (AR dirty tracking can't
165
+ # see attachment writes, and Active Storage clears `attachment_changes`
166
+ # before after_commit — hence the one-shot snapshot),
167
+ # 2. ActiveRecord's own dirty tracking (`saved_change_to_attribute?`),
168
+ # 3. `true` for a PORO/ActiveModel object with no dirty tracking at all —
169
+ # the conservative assumption.
170
+ # Override for non-attribute content the defaults can't see.
171
+ def moderation_field_changed_for_commit?(field)
172
+ return true if @moderate_changed_attachment_fields&.include?(field.to_s)
173
+
174
+ if respond_to?(:saved_change_to_attribute?)
175
+ saved_change_to_attribute?(field)
176
+ elsif respond_to?(:"saved_change_to_#{field}?")
177
+ public_send(:"saved_change_to_#{field}?")
178
+ else
179
+ true
180
+ end
181
+ end
182
+
183
+ # Per-field clean-up after the commit-time flag attempt (success OR failure).
184
+ # Consumes the one-shot attachment snapshot; override-and-super if you track
185
+ # extra per-field state of your own.
186
+ def moderation_field_committed(field)
187
+ @moderate_changed_attachment_fields&.delete(field.to_s)
188
+ nil
189
+ end
190
+
191
+ # before_save: record which FILTERED fields have a pending attachment write
192
+ # on this save. `attachment_changes` only exists on Active Storage models —
193
+ # plain models skip straight through.
194
+ def moderate_snapshot_attachment_changes
195
+ return true unless respond_to?(:attachment_changes)
196
+
197
+ moderation_filtered_fields.each do |field|
198
+ next unless attachment_changes.key?(field)
199
+
200
+ (@moderate_changed_attachment_fields ||= Set.new) << field
201
+ end
202
+ true # never halt the save chain
203
+ end
204
+
205
+ # Blank check that can see through an `ActiveStorage::Attached` proxy — an
206
+ # attachment with nothing attached must read as "nothing to classify"
207
+ # (Object#blank? can't tell: the proxy is truthy and has no #empty?).
208
+ def moderation_value_blank?(value)
209
+ return !value.attached? if value.respond_to?(:attached?)
210
+
211
+ value.blank?
212
+ end
213
+ end
214
+ end
@@ -0,0 +1,282 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moderate
4
+ # The contract a model opts into to become *reportable* — i.e. a piece of
5
+ # user-generated content (a comment, a listing, a profile) that another user (or
6
+ # a public DSA notice) can file a report against.
7
+ #
8
+ # Plain Rails polymorphism answers "what row was reported?" via the
9
+ # `Moderate::Report#reportable` association. This concern answers the
10
+ # Trust & Safety questions that polymorphism *can't*:
11
+ #
12
+ # - which fields of the record may be reported (`reportable_fields`)
13
+ # - who is *responsible* for the content (`reported_owner`) — the person a
14
+ # decision's statement of reasons must reach (DSA Art. 17) and who a ban
15
+ # would apply to
16
+ # - what the moderation queue should *call* this item (`moderation_label`)
17
+ # - what immutable evidence to snapshot at report time so it survives an edit
18
+ # or delete (`moderation_snapshot`)
19
+ # - what a moderator may *remove* without nuking the whole record
20
+ # (`remove_reported_field!`)
21
+ # - whether a given viewer is even allowed to report it (`report_visible_to?`)
22
+ #
23
+ # WHY a concern and not just config: these answers are intrinsic to each model
24
+ # (only the Listing knows its title is reportable but its internal SKU isn't),
25
+ # so they belong ON the model. Hosts override the handful of methods that need
26
+ # domain knowledge; everything else has a safe default.
27
+ #
28
+ # Regulatory grounding for why a reportable contract has to exist at all:
29
+ # - Apple App Store Review Guideline 1.2 requires a way to report UGC and a
30
+ # mechanism to remove offending content:
31
+ # https://developer.apple.com/app-store/review/guidelines/#user-generated-content
32
+ # - Google Play UGC policy requires in-app reporting and ongoing moderation:
33
+ # https://support.google.com/googleplay/android-developer/answer/9876937
34
+ # - EU DSA Art. 16 (notice & action) presupposes that reported items can be
35
+ # identified, snapshotted, and acted on:
36
+ # https://eur-lex.europa.eu/eli/reg/2022/2065/oj
37
+ #
38
+ # The documented include form is `include Moderate::Reportable` +
39
+ # `reportable_fields :a, :b`; the `has_reportable_content :a, :b` macro is exact sugar.
40
+ module Reportable
41
+ extend ActiveSupport::Concern
42
+
43
+ included do
44
+ # Reports filed against THIS record. Kept on the public `reports` reader
45
+ # because the README promises `listing.reports`, and because a reportable
46
+ # model should read naturally in host code. Reports are legal/evidentiary,
47
+ # so hard-deleting the content detaches rather than destroys them.
48
+ has_many :reports,
49
+ as: :reportable,
50
+ class_name: "Moderate::Report",
51
+ dependent: :nullify
52
+
53
+ # The whitelist of reportable field names, stored as frozen Strings. A
54
+ # `class_attribute` (not a plain constant) so it inherits down an STI tree
55
+ # AND can be overridden per subclass without mutating the parent's list.
56
+ # Empty default = "the whole record is reportable, no specific field."
57
+ class_attribute :moderation_reportable_fields, instance_writer: false, default: [].freeze
58
+
59
+ # Self-register in the gem's reportable registry the moment the concern is
60
+ # included, so `Moderate.reportable_classes` is auto-discovered with NO
61
+ # manual list to maintain (README: "Reportable classes are auto-discovered
62
+ # from the `has_reportable_content` macro — no manual registry."). We register the class
63
+ # NAME (the registry stores strings and constantizes lazily) so we never pin
64
+ # the class across a Zeitwerk reload in development.
65
+ Moderate.register_reportable(self)
66
+ end
67
+
68
+ class_methods do
69
+ # Declare (or read) the reportable fields. Idempotent and additive-free:
70
+ # the last declaration wins for THIS class (it doesn't merge with the
71
+ # inherited value), matching how a host expects `reportable_fields :title`
72
+ # to mean exactly `["title"]`. Called with no args, it's a reader.
73
+ #
74
+ # reportable_fields :title, :description # writer
75
+ # reportable_fields # => ["title", "description"]
76
+ def reportable_fields(*fields)
77
+ self.moderation_reportable_fields = fields.map(&:to_s).freeze if fields.any?
78
+ moderation_reportable_fields
79
+ end
80
+ end
81
+
82
+ # Is `field` one a reporter is allowed to name? Two cases:
83
+ #
84
+ # - A BLANK field is ALWAYS allowed — it means "report the whole record," which
85
+ # is valid whether or not the model declared specific reportable fields. (A
86
+ # user tapping "Report this comment" doesn't name a field; only the public DSA
87
+ # notice / a field-targeted in-app flow does.) So a Comment that declares
88
+ # `has_reportable_content :body` can still be reported as a whole with a nil field.
89
+ #
90
+ # - A NAMED field must be in the whitelist. With no fields declared, the
91
+ # whitelist is empty, so any named field is rejected (there's nothing to
92
+ # target field-by-field on a bare-`has_reportable_content` record).
93
+ #
94
+ # This is the authorization gate the Report model and the report controller both
95
+ # consult before accepting a `reported_field`.
96
+ def reportable_field_allowed?(field)
97
+ field_s = field.to_s
98
+ return true if field_s.empty?
99
+
100
+ self.class.reportable_fields.include?(field_s)
101
+ end
102
+
103
+ # WHO is responsible for this content — the account a decision's statement of
104
+ # reasons reaches (DSA Art. 17) and the user a ban would apply to.
105
+ #
106
+ # NO default: a model that can be reported MUST tell the gem who's behind it,
107
+ # because guessing wrong here means notifying or banning the wrong person.
108
+ # We raise a NotImplementedError naming the class so the omission is loud at
109
+ # the first report, not silent. (A `User` model with `has_reporting_and_blocking` is itself
110
+ # reportable and returns `self` — see Moderate::Actor.)
111
+ def reported_owner
112
+ raise NotImplementedError,
113
+ "#{self.class.name} is reportable but doesn't define #reported_owner. " \
114
+ "Return the user responsible for this content (the one a decision notifies " \
115
+ "and a ban applies to), e.g. `def reported_owner = user`."
116
+ end
117
+
118
+ # The human-readable name the moderation queue shows for this item. Defaults
119
+ # to Rails' own `to_s` (usually "#<Comment id: 42>"); override for something an
120
+ # admin can act on at a glance, e.g. `def moderation_label = "Comment #{id}"`.
121
+ def moderation_label
122
+ to_s
123
+ end
124
+
125
+ # The immutable evidence text to capture for `field` at report time, so the
126
+ # report survives the content being edited or deleted. The Report model calls
127
+ # this in its `before_validation :capture_snapshot` (DSA-grade evidence
128
+ # preservation). Defaults to nil; override to return the actual field text
129
+ # (e.g. `public_send(field)`) or a description of a non-text attachment.
130
+ #
131
+ # NAMED `moderation_snapshot` per the gem's public reportable contract; takes
132
+ # the field so a multi-field record can snapshot the specific thing reported.
133
+ def moderation_snapshot(_field)
134
+ nil
135
+ end
136
+
137
+ # Remove the reported `field` as an enforcement action — WITHOUT destroying the
138
+ # whole record (a moderator removing one objectionable photo shouldn't delete
139
+ # the whole listing). Returns truthy if something was actually removed (so the
140
+ # decision service knows whether to fire the `content_removed` event).
141
+ #
142
+ # Defaults to a no-op returning false: a model that hasn't opted into
143
+ # field-level removal simply reports "nothing removed," and the moderator falls
144
+ # back to other actions. Override to purge an attachment, blank a column, etc.
145
+ def remove_reported_field!(_field)
146
+ false
147
+ end
148
+
149
+ # Companion query to `remove_reported_field!`: CAN this specific `field` be
150
+ # removed on this record? An admin UI uses it to decide whether to OFFER a
151
+ # "remove content" action at all. Without it, a host that only removes SOME
152
+ # fields (e.g. an avatar but not a display name) would render a remove button
153
+ # that always fails when the moderator clicks it on a non-removable field.
154
+ #
155
+ # Defaults to false (mirrors the no-op `remove_reported_field!`). Override it
156
+ # alongside `remove_reported_field!` and have the latter reuse it, so the
157
+ # "can I?" answer and the "do it" action never drift apart.
158
+ def removable_reported_field?(_field)
159
+ false
160
+ end
161
+
162
+ # Visibility/authorization gate for the report affordance: should `viewer` be
163
+ # offered a "report this" control for `field`? The default enforces two rules:
164
+ #
165
+ # 1. the field must be reportable (`reportable_field_allowed?`), and
166
+ # 2. you can't report YOUR OWN content — the affordance is hidden from the
167
+ # content's owner, so an author never sees "Report" on their own post. We
168
+ # compare the viewer to this content's `reported_owner` (the Reportable
169
+ # contract's "who is responsible" answer).
170
+ #
171
+ # The `moderate_report_link` helper renders nothing when this is false, and the
172
+ # report controller redirects. Hosts can override for richer rules. (A User with
173
+ # `has_reporting_and_blocking` overrides this in Moderate::Actor to compare ids directly,
174
+ # since a user IS its own owner.)
175
+ def report_visible_to?(viewer, field:)
176
+ return false unless reportable_field_allowed?(field)
177
+ return false if viewer.present? && moderation_owner_is?(viewer)
178
+
179
+ true
180
+ end
181
+
182
+ # Open reports filed against this record, optionally narrowed to one field.
183
+ # Hosts can use this when they need the actual relation (queue previews,
184
+ # counters, "already reported" affordances) instead of just a boolean.
185
+ def open_reports(field = nil)
186
+ moderation_scope_by_field(reports.open, :reported_field, field)
187
+ end
188
+
189
+ # Has this record received any open reports? This is the public predicate
190
+ # documented beside `reports` in the README.
191
+ def reported?(field = nil)
192
+ open_reports(field).exists?
193
+ end
194
+
195
+ # All auto-filter/manual flags against this record, optionally narrowed to a
196
+ # field. We keep this as a method instead of a `has_many :flags` association:
197
+ # `flags` is a common host-model word, while `flagged?` is the public DX.
198
+ def moderation_flags(field = nil)
199
+ moderation_scope_by_field(
200
+ Moderate::Flag.where(flaggable: self),
201
+ :field,
202
+ field
203
+ )
204
+ end
205
+
206
+ # Pending flags are the "allowed through, awaiting review" state a host may
207
+ # want to surface near user-generated content.
208
+ def pending_moderation_flags(field = nil)
209
+ moderation_flags(field).pending
210
+ end
211
+
212
+ # Has this record been flagged and not yet resolved/dismissed? Optionally
213
+ # pass a field (`listing.flagged?(:description)`) for field-level UI.
214
+ def flagged?(field = nil)
215
+ pending_moderation_flags(field).exists?
216
+ end
217
+
218
+ # --- Route descriptor hooks -----------------------------------------------
219
+ #
220
+ # The gem is UI-agnostic and doesn't know the host's routes, so a reportable
221
+ # tells the gem how to build the few URLs/paths that decision notices and the
222
+ # public DSA notice form need. Each receives the host's route proxy (whatever
223
+ # responds to the app's `*_url`/`*_path` helpers — typically the controller or
224
+ # `Rails.application.routes.url_helpers`) and returns a string or nil.
225
+ #
226
+ # All default to nil — the gem treats a missing route as "no link available"
227
+ # and degrades gracefully (e.g. the statement of reasons just omits the link,
228
+ # the post-report redirect falls back to the app root). Hosts override only the
229
+ # ones their flows actually surface.
230
+
231
+ # The public, canonical URL of this content — what a DSA Art. 16 notice records
232
+ # as the precise location of the allegedly illegal content, and what a
233
+ # statement of reasons points the affected user to.
234
+ def moderation_subject_url(_routes)
235
+ nil
236
+ end
237
+
238
+ # Where to send a user *back to* after they file a report on this content
239
+ # (e.g. the content's own page). Falls back to the app root when nil.
240
+ def moderation_return_path(_routes)
241
+ nil
242
+ end
243
+
244
+ # The admin/back-office path for this content, so the moderation queue can deep
245
+ # link a reviewer straight to the item under review.
246
+ def moderation_admin_path(_routes)
247
+ nil
248
+ end
249
+
250
+ private
251
+
252
+ # Is `viewer` the user responsible for this content? Used by the default
253
+ # `report_visible_to?` to hide the report affordance from the content's own owner.
254
+ #
255
+ # We resolve ownership via the Reportable contract's `reported_owner`, but guard
256
+ # it carefully: `reported_owner` deliberately RAISES NotImplementedError on a
257
+ # reportable that hasn't defined it (so the omission is loud at report time). A
258
+ # visibility check, by contrast, must never blow up a view — so we rescue that
259
+ # case and treat "unknown owner" as "not the viewer" (show the link; the write
260
+ # path will still surface the missing-owner error if they actually report). We
261
+ # compare by primary key when both sides are AR records, else by object equality.
262
+ def moderation_owner_is?(viewer)
263
+ owner = reported_owner
264
+ return false if owner.nil?
265
+
266
+ if owner.respond_to?(:id) && viewer.respond_to?(:id)
267
+ owner.id == viewer.id && owner.class == viewer.class
268
+ else
269
+ owner == viewer
270
+ end
271
+ rescue NotImplementedError
272
+ false
273
+ end
274
+
275
+ def moderation_scope_by_field(scope, column, field)
276
+ field_s = field.to_s.squish
277
+ return scope if field_s.blank?
278
+
279
+ scope.where(column => field_s)
280
+ end
281
+ end
282
+ end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moderate
4
+ # A system/auto-filter flag: the record an adapter (or a manual action) leaves
5
+ # behind when content is flagged for review rather than rejected outright.
6
+ #
7
+ # Flags are what `:flag`-mode filtering produces AFTER COMMIT — the write
8
+ # succeeds, then a Flag lands in the queue. This is intentional and load-bearing:
9
+ # a flag must NEVER be created inside a validator/transaction, because a validator
10
+ # is supposed to be side-effect-free and a Flag written inside a transaction that
11
+ # later rolls back would silently vanish (you'd "moderate" content that was never
12
+ # saved). The Filterable concern enforces the after_commit timing; this model is
13
+ # just the record + the queue + the "content flagged" notification.
14
+ #
15
+ # The same `pending` scope serves BOTH a human admin queue and an automated
16
+ # consumer (e.g. a job that auto-actions high-confidence flags), so the gem
17
+ # doesn't presume a human is the only reviewer.
18
+ class Flag < ApplicationRecord
19
+ self.table_name = "moderate_flags"
20
+
21
+ STATUSES = %w[pending actioned dismissed].freeze
22
+
23
+ # Built-in/generic source names. Host-registered adapter names are also valid
24
+ # sources (see `.sources` below) because `Moderate.classify` stamps the adapter
25
+ # name onto the Result when the adapter does not set one explicitly. This is why
26
+ # a host can register `:openai` or `:image` and see that exact name in the queue.
27
+ SOURCES = %w[text_filter image_filter external_classifier manual].freeze
28
+
29
+ # What the flag WOULD do. `:flag` allowed the write and queued it; `:block`
30
+ # rejected the write (a block-mode trip can also be recorded as a flag for the
31
+ # audit trail). Validated by the model (inclusion), not a DB constraint.
32
+ MODES = %w[flag block].freeze
33
+
34
+ # The flagged content is polymorphic — any `Moderate::Reportable`. `owner` is the
35
+ # responsible user (inferred from the flaggable's `reported_owner`), kept here so
36
+ # the queue can group flags by user without re-resolving ownership; optional
37
+ # because not all content has a single owning account. `reviewed_by` is the
38
+ # moderator who closed it. All user associations resolve to the host's configured
39
+ # user class, read lazily from config as a String.
40
+ belongs_to :flaggable, polymorphic: true
41
+ belongs_to :owner, class_name: Moderate.config.user_class, optional: true
42
+ belongs_to :reviewed_by, class_name: Moderate.config.user_class, optional: true
43
+
44
+ # Default the JSON columns to their empty shape before save: `categories` is a
45
+ # list ([]), `scores`/`context` are hashes ({}). The migration makes all three
46
+ # NOT NULL, but MySQL 8+ forbids a JSON DEFAULT, so a Flag built directly (rather
47
+ # than via `flag!`, which already coerces them) could write NULL and trip a
48
+ # NotNullViolation on MySQL. (SQLite/PostgreSQL get the defaults from the
49
+ # migration; coalescing is harmless there.)
50
+ before_save :default_json_columns
51
+
52
+ # Announce a new flag through the host's notify hook so admins get pinged (e.g.
53
+ # a Telegram alert) the moment content is flagged. after_create_COMMIT, not
54
+ # after_create: the flag must be durably saved before we tell anyone about it,
55
+ # and the notify must not be able to roll the flag back.
56
+ after_create_commit :notify_content_flagged
57
+
58
+ scope :pending, -> { where(status: "pending") }
59
+ scope :actioned, -> { where(status: "actioned") }
60
+ scope :dismissed, -> { where(status: "dismissed") }
61
+ scope :recent_first, -> { order(created_at: :desc) }
62
+
63
+ validates :field, presence: true
64
+ validates :status, inclusion: { in: STATUSES }
65
+ validates :source, inclusion: { in: ->(_flag) { sources } }, on: :create
66
+ validates :mode, inclusion: { in: MODES }
67
+ validates :resolution_note, presence: true, if: :closed?
68
+
69
+ # The single entry point the Filterable concern uses to file a flag. Centralizes
70
+ # coercion (categories → Array, scores/context → Hash) so callers can hand us
71
+ # whatever shape a Moderate::Result exposed without each one re-normalizing.
72
+ # `context` is freeform JSON for audit (which policy fired, the raw classifier
73
+ # payload, etc.) — never relied on by the gem's own logic.
74
+ def self.flag!(flaggable:, field:, owner:, source:, mode:, excerpt:, categories:, scores:, context:)
75
+ create!(
76
+ flaggable: flaggable,
77
+ field: field,
78
+ owner: owner,
79
+ source: source,
80
+ mode: mode,
81
+ excerpt: excerpt,
82
+ categories: Array(categories),
83
+ scores: scores.to_h,
84
+ context: context.to_h
85
+ )
86
+ end
87
+
88
+ def self.sources
89
+ (SOURCES + Moderate.config.adapters.keys.map(&:to_s)).uniq
90
+ end
91
+
92
+ def pending?
93
+ status == "pending"
94
+ end
95
+
96
+ def closed?
97
+ status.in?(%w[actioned dismissed])
98
+ end
99
+
100
+ # Close this flag as ACTIONED — someone (or an automated policy) took
101
+ # action on the flagged content. Model-level sugar mirroring
102
+ # Report#resolve!/#dismiss! so hosts stop hand-writing status updates.
103
+ # `by:` is the reviewing user when a human decided; leave it nil for
104
+ # automated closes — recording a human who never looked would poison the
105
+ # audit trail (DSA statements of reasons read these rows).
106
+ def action!(note:, by: nil)
107
+ close!(new_status: "actioned", note: note, by: by)
108
+ end
109
+
110
+ # Close this flag with NO action taken. The canonical automated use is
111
+ # SUPERSEDED content: when the flagged field changes (text edited, photo
112
+ # replaced/reverted), a still-pending flag is about content that is no
113
+ # longer live — leaving it pending keeps `flagged?` true and mislabels
114
+ # the NEW content in any host UI keyed on it. Hosts should dismiss with a
115
+ # note saying what superseded it.
116
+ def dismiss!(note:, by: nil)
117
+ close!(new_status: "dismissed", note: note, by: by)
118
+ end
119
+
120
+ # A label for the flagged thing in the queue. Asks the flaggable for its own
121
+ # `moderation_label` (Moderate::Reportable interface); falls back to a generic
122
+ # "Type id" string for content that doesn't implement it.
123
+ def flaggable_label
124
+ return flaggable.moderation_label if flaggable.respond_to?(:moderation_label)
125
+
126
+ "#{flaggable_type} #{flaggable_id}"
127
+ end
128
+
129
+ private
130
+
131
+ # Shared close path for action!/dismiss!. update! (not update_columns) on
132
+ # purpose: the `resolution_note presence if closed?` validation is the
133
+ # guard that every closed flag explains itself — sugar that skipped it
134
+ # would defeat the reason the sugar exists.
135
+ def close!(new_status:, note:, by:)
136
+ update!(
137
+ status: new_status,
138
+ resolution_note: note,
139
+ reviewed_by: by,
140
+ reviewed_at: Time.current
141
+ )
142
+ end
143
+
144
+ # See the before_save comment: keep the NOT-NULL JSON columns non-null on MySQL.
145
+ def default_json_columns
146
+ self.categories ||= []
147
+ self.scores ||= {}
148
+ self.context ||= {}
149
+ end
150
+
151
+ def notify_content_flagged
152
+ # `content_flagged` has NO user recipient by design — it's an admin/system
153
+ # signal, not a message to the content owner — so the Event's recipients stay
154
+ # empty and the host's notify hook routes it to admin channels only.
155
+ Moderate.notify(
156
+ :content_flagged,
157
+ subject: self,
158
+ payload: {
159
+ flaggable_type: flaggable_type,
160
+ flaggable_id: flaggable_id,
161
+ field: field,
162
+ source: source,
163
+ categories: Array(categories),
164
+ summary: "content flagged (#{source}) on #{flaggable_label}##{field}"
165
+ }
166
+ )
167
+ end
168
+ end
169
+ end