moderate 0.1.0 → 1.0.0.beta1

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 (65) 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 +71 -1
  7. data/CLAUDE.md +7 -0
  8. data/README.md +376 -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/docs/compliance.md +178 -0
  24. data/docs/configuration.md +326 -0
  25. data/docs/dsa-notice-form.md +371 -0
  26. data/docs/madmin.md +490 -0
  27. data/docs/notifications.md +363 -0
  28. data/examples/aws_rekognition_adapter.rb +140 -0
  29. data/examples/openai_moderation_adapter.rb +111 -0
  30. data/gemfiles/rails_7.1.gemfile +36 -0
  31. data/gemfiles/rails_7.2.gemfile +36 -0
  32. data/gemfiles/rails_8.1.gemfile +36 -0
  33. data/lib/generators/moderate/install_generator.rb +56 -0
  34. data/lib/generators/moderate/templates/create_moderate_tables.rb.erb +237 -0
  35. data/lib/generators/moderate/templates/initializer.rb +198 -0
  36. data/lib/generators/moderate/views_generator.rb +63 -0
  37. data/lib/moderate/configuration.rb +341 -0
  38. data/lib/moderate/engine.rb +138 -0
  39. data/lib/moderate/errors.rb +26 -0
  40. data/lib/moderate/event.rb +75 -0
  41. data/lib/moderate/filters/base.rb +126 -0
  42. data/lib/moderate/filters/wordlist.rb +255 -0
  43. data/lib/moderate/jobs/classify_job.rb +158 -0
  44. data/lib/moderate/label.rb +111 -0
  45. data/lib/moderate/macros.rb +90 -0
  46. data/lib/moderate/models/appeal.rb +154 -0
  47. data/lib/moderate/models/application_record.rb +31 -0
  48. data/lib/moderate/models/block.rb +203 -0
  49. data/lib/moderate/models/concerns/actor.rb +174 -0
  50. data/lib/moderate/models/concerns/content_filterable.rb +155 -0
  51. data/lib/moderate/models/concerns/reportable.rb +282 -0
  52. data/lib/moderate/models/flag.rb +136 -0
  53. data/lib/moderate/models/report.rb +620 -0
  54. data/lib/moderate/result.rb +176 -0
  55. data/lib/moderate/services/intake_appeal.rb +89 -0
  56. data/lib/moderate/services/intake_notice.rb +132 -0
  57. data/lib/moderate/services/intake_report.rb +132 -0
  58. data/lib/moderate/services/resolve_appeal.rb +134 -0
  59. data/lib/moderate/services/resolve_flag.rb +101 -0
  60. data/lib/moderate/services/resolve_report.rb +291 -0
  61. data/lib/moderate/version.rb +1 -1
  62. data/lib/moderate.rb +365 -18
  63. data/log/development.log +0 -0
  64. data/log/test.log +0 -0
  65. metadata +154 -15
@@ -0,0 +1,155 @@
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 and the value trips the filter) a
20
+ # `Moderate::Flag` is filed for review.
21
+ #
22
+ # WHY :flag lives in after_commit and not in a validator (this is the whole
23
+ # reason `:flag` is a `moderates` mode you can't hand-roll with `validates`):
24
+ # validators must be side-effect-free, and a Flag created inside a transaction
25
+ # that later rolls back would silently vanish — you'd think you flagged something
26
+ # you didn't. `after_commit` guarantees the surrounding transaction committed
27
+ # before we write the Flag. See docs/configuration.md ("`:flag` never lives in a
28
+ # validator").
29
+ module ContentFilterable
30
+ extend ActiveSupport::Concern
31
+
32
+ included do
33
+ # The set of filtered field names (Strings), inherited via class_attribute so
34
+ # STI subclasses keep their parent's filtered fields. Accumulates across
35
+ # multiple `moderates`/`moderates_fields` declarations on the same class.
36
+ class_attribute :moderation_filtered_fields, instance_writer: false, default: [].freeze
37
+
38
+ validate :moderate_blocked_fields_must_be_allowed
39
+ after_commit :moderate_flag_filtered_fields
40
+ end
41
+
42
+ class_methods do
43
+ # Register one or more fields for filtering. Additive and de-duplicated, so
44
+ # `moderates :a; moderates :b` and `moderates_fields :a, :b` are equivalent.
45
+ # The PER-FIELD adapter/mode (the `with:`/`mode:` of the `moderates` macro)
46
+ # is recorded separately as a Configuration FilterPolicy by the macro; this
47
+ # method only tracks WHICH fields to run on commit/validate.
48
+ def moderates_fields(*fields)
49
+ self.moderation_filtered_fields =
50
+ (moderation_filtered_fields + fields.map(&:to_s)).uniq.freeze
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ # :block enforcement — a validation. We deliberately skip :flag fields here
57
+ # (their work happens after_commit) and blank values (nothing to classify).
58
+ def moderate_blocked_fields_must_be_allowed
59
+ moderation_filtered_fields.each do |field|
60
+ policy = Moderate.filter_policy_for(self, field)
61
+ next unless policy.block?
62
+
63
+ value = moderation_field_value(field)
64
+ next if value.blank?
65
+
66
+ result = Moderate.classify(value, policy: policy)
67
+ errors.add(field, :objectionable_content) if result.flagged?
68
+ end
69
+ end
70
+
71
+ # :flag enforcement — an after_commit side effect that files a Moderate::Flag.
72
+ #
73
+ # We only act when the field actually CHANGED on this commit (re-saving an
74
+ # untouched record must not re-flag it and spam the queue), and we wrap each
75
+ # field in `begin/ensure` so a clean-up hook (`moderation_field_committed`)
76
+ # always runs even if classification raises — important for the attachment
77
+ # seam below, where a host sets a one-shot "changed" flag it must clear.
78
+ def moderate_flag_filtered_fields
79
+ moderation_filtered_fields.each do |field|
80
+ policy = Moderate.filter_policy_for(self, field)
81
+ next unless policy.flag?
82
+ next unless moderation_field_changed_for_commit?(field)
83
+
84
+ begin
85
+ value = moderation_field_value(field)
86
+ next if value.blank?
87
+
88
+ result = Moderate.classify(value, policy: policy)
89
+ next unless result.flagged?
90
+
91
+ Moderate::Flag.flag!(
92
+ flaggable: self,
93
+ field: field,
94
+ # `reported_owner` comes from Moderate::Reportable. A filterable model
95
+ # is usually also reportable; if it isn't, the owner is simply nil
96
+ # (the flag still lands in the queue, just unattributed).
97
+ owner: (reported_owner if respond_to?(:reported_owner)),
98
+ source: result.source,
99
+ mode: policy.mode,
100
+ # Cap the stored excerpt so we never balloon a row with a huge body;
101
+ # 500 chars is plenty of context for a reviewer.
102
+ excerpt: value.to_s.truncate(500),
103
+ categories: result.categories,
104
+ scores: result.scores,
105
+ # Keep the policy that produced the flag alongside the adapter's raw
106
+ # payload, so the queue can show "flagged by <adapter> under
107
+ # <Class>#<field>" and an auditor can inspect the untouched response.
108
+ context: {
109
+ raw: result.raw,
110
+ policy: { class_name: policy.class_name, field: policy.field, mode: policy.mode.to_s }
111
+ }
112
+ )
113
+ ensure
114
+ moderation_field_committed(field)
115
+ end
116
+ end
117
+ end
118
+
119
+ # --- Overridable field seam -----------------------------------------------
120
+ #
121
+ # These three methods are the seam that lets one concern filter BOTH plain text
122
+ # columns AND non-column content (e.g. an Active Storage attachment), without
123
+ # the concern knowing anything about attachments. Defaults handle the common
124
+ # "it's a text attribute" case; a host overrides them for richer content.
125
+
126
+ # The value to classify for `field`. Default: the attribute reader. Override to
127
+ # return, say, an attachment's blob/URL for an image adapter. The classifier
128
+ # (text or image) is whatever the field's policy adapter is.
129
+ def moderation_field_value(field)
130
+ public_send(field)
131
+ end
132
+
133
+ # Did `field` change on the just-committed save? Default: ask ActiveRecord's
134
+ # dirty tracking (`saved_change_to_attribute?`). We guard with `respond_to?`
135
+ # so the concern also works on a PORO/ActiveModel object that lacks AR dirty
136
+ # tracking (in which case we conservatively assume it changed). Override for
137
+ # non-attribute content (e.g. track an attachment's "was replaced" flag).
138
+ def moderation_field_changed_for_commit?(field)
139
+ if respond_to?(:saved_change_to_attribute?)
140
+ saved_change_to_attribute?(field)
141
+ elsif respond_to?(:"saved_change_to_#{field}?")
142
+ public_send(:"saved_change_to_#{field}?")
143
+ else
144
+ true
145
+ end
146
+ end
147
+
148
+ # Per-field clean-up after the commit-time flag attempt (success OR failure).
149
+ # No-op by default; the attachment seam overrides it to reset a one-shot
150
+ # "changed" flag it set during the save.
151
+ def moderation_field_committed(_field)
152
+ nil
153
+ end
154
+ end
155
+ 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,136 @@
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
+ # A label for the flagged thing in the queue. Asks the flaggable for its own
101
+ # `moderation_label` (Moderate::Reportable interface); falls back to a generic
102
+ # "Type id" string for content that doesn't implement it.
103
+ def flaggable_label
104
+ return flaggable.moderation_label if flaggable.respond_to?(:moderation_label)
105
+
106
+ "#{flaggable_type} #{flaggable_id}"
107
+ end
108
+
109
+ private
110
+
111
+ # See the before_save comment: keep the NOT-NULL JSON columns non-null on MySQL.
112
+ def default_json_columns
113
+ self.categories ||= []
114
+ self.scores ||= {}
115
+ self.context ||= {}
116
+ end
117
+
118
+ def notify_content_flagged
119
+ # `content_flagged` has NO user recipient by design — it's an admin/system
120
+ # signal, not a message to the content owner — so the Event's recipients stay
121
+ # empty and the host's notify hook routes it to admin channels only.
122
+ Moderate.notify(
123
+ :content_flagged,
124
+ subject: self,
125
+ payload: {
126
+ flaggable_type: flaggable_type,
127
+ flaggable_id: flaggable_id,
128
+ field: field,
129
+ source: source,
130
+ categories: Array(categories),
131
+ summary: "content flagged (#{source}) on #{flaggable_label}##{field}"
132
+ }
133
+ )
134
+ end
135
+ end
136
+ end