moderate 1.0.0.beta1 → 1.0.0

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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +96 -1
  3. data/.simplecov +2 -10
  4. data/AGENTS.md +1 -1
  5. data/Appraisals +10 -8
  6. data/CHANGELOG.md +660 -20
  7. data/CLAUDE.md +1 -1
  8. data/README.md +149 -7
  9. data/Rakefile +2 -0
  10. data/app/controllers/concerns/moderate/moderation.rb +23 -11
  11. data/app/controllers/moderate/appeals_controller.rb +9 -5
  12. data/app/controllers/moderate/application_controller.rb +3 -3
  13. data/app/controllers/moderate/notices_controller.rb +3 -6
  14. data/app/helpers/moderate/engine_helper.rb +32 -11
  15. data/app/views/moderate/appeals/new.html.erb +6 -0
  16. data/app/views/moderate/transparency_reports/_summary_card.html.erb +2 -2
  17. data/app/views/moderate/transparency_reports/show.html.erb +5 -5
  18. data/config/locales/en.yml +200 -0
  19. data/config/locales/es.yml +177 -0
  20. data/context7.json +4 -0
  21. data/db/migrate/20261018200000_add_pending_key_to_moderate_flags.rb +139 -0
  22. data/db/migrate/20261018210000_add_retention_and_classifications_to_moderate_tables.rb +103 -0
  23. data/docs/chats.md +140 -0
  24. data/docs/compliance.md +92 -3
  25. data/docs/configuration.md +30 -2
  26. data/docs/dsa-notice-form.md +1 -1
  27. data/docs/images/moderate-user-report-block-actions.webp +0 -0
  28. data/docs/madmin.md +44 -14
  29. data/docs/notifications.md +17 -4
  30. data/examples/aws_rekognition_adapter.rb +6 -6
  31. data/examples/openai_moderation_adapter.rb +1 -1
  32. data/examples/regexp_pattern_adapter.rb +68 -0
  33. data/gemfiles/{rails_7.1.gemfile → rails_8.0.gemfile} +8 -6
  34. data/gemfiles/rails_8.1.gemfile +6 -5
  35. data/lib/generators/moderate/install_generator.rb +25 -0
  36. data/lib/generators/moderate/templates/create_moderate_tables.rb.erb +17 -2
  37. data/lib/generators/moderate/templates/initializer.rb +9 -15
  38. data/lib/generators/moderate/views_generator.rb +3 -3
  39. data/lib/moderate/attachments.rb +137 -0
  40. data/lib/moderate/configuration.rb +101 -12
  41. data/lib/moderate/engine.rb +86 -25
  42. data/lib/moderate/event.rb +21 -0
  43. data/lib/moderate/filters/base.rb +1 -0
  44. data/lib/moderate/filters/wordlist.rb +5 -2
  45. data/lib/moderate/jobs/classify_job.rb +100 -16
  46. data/lib/moderate/label.rb +2 -2
  47. data/lib/moderate/macros.rb +8 -3
  48. data/lib/moderate/models/appeal.rb +10 -1
  49. data/lib/moderate/models/block.rb +23 -19
  50. data/lib/moderate/models/classification.rb +24 -0
  51. data/lib/moderate/models/concerns/actor.rb +17 -2
  52. data/lib/moderate/models/concerns/content_filterable.rb +241 -56
  53. data/lib/moderate/models/concerns/deferred_notices.rb +90 -0
  54. data/lib/moderate/models/concerns/reportable.rb +84 -4
  55. data/lib/moderate/models/flag.rb +199 -3
  56. data/lib/moderate/models/report.rb +180 -16
  57. data/lib/moderate/result.rb +3 -10
  58. data/lib/moderate/retention.rb +241 -0
  59. data/lib/moderate/services/intake_notice.rb +10 -10
  60. data/lib/moderate/services/intake_report.rb +42 -13
  61. data/lib/moderate/services/resolve_appeal.rb +8 -2
  62. data/lib/moderate/services/resolve_flag.rb +183 -35
  63. data/lib/moderate/services/resolve_report.rb +288 -35
  64. data/lib/moderate/text_validator.rb +15 -14
  65. data/lib/moderate/version.rb +1 -1
  66. data/lib/moderate.rb +337 -17
  67. data/lib/tasks/moderate.rake +23 -0
  68. metadata +24 -14
  69. data/gemfiles/rails_7.2.gemfile +0 -36
  70. data/lib/moderate/text.rb +0 -38
  71. data/lib/moderate/word_list.rb +0 -90
@@ -16,19 +16,88 @@ module Moderate
16
16
  # Configuration validates that invariant (README: ":block requires a
17
17
  # synchronous adapter").
18
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.
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.
21
27
  #
22
28
  # WHY :flag lives in after_commit and not in a validator (this is the whole
23
29
  # reason `:flag` is a `moderates` mode you can't hand-roll with `validates`):
24
30
  # validators must be side-effect-free, and a Flag created inside a transaction
25
31
  # that later rolls back would silently vanish — you'd think you flagged something
26
32
  # 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").
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 drops `attachment_changes` in its own 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
+ #
44
+ # An attachment is classified only once its file is on the storage service, and
45
+ # it is classified even when the upload fails. Active Storage uploads a newly
46
+ # attached file in ITS after_commit (in whichever order the two after_commits were
47
+ # declared), so the concern hooks that upload itself, from before_save:
48
+ #
49
+ # 1. just before the upload starts, a ClassifyJob is enqueued UPLOAD_WAIT seconds
50
+ # out: the durable intent. If the upload raises, or the process dies, that job
51
+ # still runs, waits (bounded) for the file, and classifies it or audits
52
+ # `classification_skipped`;
53
+ # 2. once the upload returns, the field is classified right away when its files
54
+ # are on the service: inline for a synchronous adapter, by an immediate
55
+ # ClassifyJob for an async one. A direct upload (an attached blob or signed
56
+ # id, whose `upload` is a no-op) may not be there yet; then the job from step
57
+ # 1 takes it.
58
+ #
59
+ # Whichever classifies first claims the files (a moderate_classifications row,
60
+ # Moderate::Attachments), calls the adapter outside any lock and records the
61
+ # result; the rest find the claim or the record and stop, so a field is classified
62
+ # once per set of files. An adapter that raises leaves its claim until
63
+ # `config.classification_claim_ttl` runs out and schedules no retry itself: the
64
+ # guard job, a job's recheck at the claim's expiry, or a new save takes it over.
29
65
  module ContentFilterable
30
66
  extend ActiveSupport::Concern
31
67
 
68
+ # Extended onto one pending Active Storage attachment change (the object
69
+ # `has_one_attached` / `has_many_attached` upload from their after_commit), so
70
+ # the record hears just before and just after that change uploads its files.
71
+ # Per object, never a global patch. A failure of ours never breaks the upload.
72
+ module AfterUpload
73
+ def upload(...)
74
+ moderate_notify(:moderate_upload_starting)
75
+ result = super
76
+ moderate_notify(:moderate_upload_finished)
77
+ result
78
+ end
79
+
80
+ def moderate_watch(record, field)
81
+ (@moderate_watchers ||= {})[field.to_s] = record
82
+ end
83
+
84
+ private
85
+
86
+ def moderate_notify(event)
87
+ Array(@moderate_watchers).each do |field, record|
88
+ record.send(event, field)
89
+ rescue StandardError => error
90
+ Moderate.audit(
91
+ :classification_skipped,
92
+ subject: record,
93
+ payload: { field: field, reason: "filter_error", error_class: error.class.name,
94
+ summary: "#{record.class.name}##{field}: #{event} raised #{error.class}" }
95
+ )
96
+ end
97
+ end
98
+ end
99
+ private_constant :AfterUpload
100
+
32
101
  included do
33
102
  # The set of filtered field names (Strings), inherited via class_attribute so
34
103
  # STI subclasses keep their parent's filtered fields. Accumulates across
@@ -36,6 +105,10 @@ module Moderate
36
105
  class_attribute :moderation_filtered_fields, instance_writer: false, default: [].freeze
37
106
 
38
107
  validate :moderate_blocked_fields_must_be_allowed
108
+ # Snapshot attachment writes BEFORE Active Storage's own save callbacks
109
+ # clear `attachment_changes` — by after_commit they're gone (see
110
+ # activestorage's Attached::Model). One-shot; consumed + cleared below.
111
+ before_save :moderate_snapshot_attachment_changes
39
112
  after_commit :moderate_flag_filtered_fields
40
113
  end
41
114
 
@@ -55,87 +128,167 @@ module Moderate
55
128
 
56
129
  # :block enforcement — a validation. We deliberately skip :flag fields here
57
130
  # (their work happens after_commit) and blank values (nothing to classify).
131
+ #
132
+ # `:block` lets nothing in unverified: an attachment that points at a stored blob
133
+ # whose file isn't on the service (a direct upload that hasn't finished) fails
134
+ # with `:upload_not_finished` instead of being classified without its file.
58
135
  def moderate_blocked_fields_must_be_allowed
59
136
  moderation_filtered_fields.each do |field|
60
137
  policy = Moderate.filter_policy_for(self, field)
61
138
  next unless policy.block?
62
139
 
63
140
  value = moderation_field_value(field)
64
- next if value.blank?
141
+ next if moderation_value_blank?(value)
142
+ next errors.add(field, :upload_not_finished) if Moderate::Attachments.upload_unfinished?(value)
65
143
 
66
144
  result = Moderate.classify(value, policy: policy)
67
145
  errors.add(field, :objectionable_content) if result.flagged?
68
146
  end
69
147
  end
70
148
 
71
- # :flag enforcement — an after_commit side effect that files a Moderate::Flag.
149
+ # :flag enforcement — an after_commit side effect that files a Moderate::Flag
150
+ # (inline for synchronous adapters; via Moderate::ClassifyJob for async ones).
72
151
  #
73
152
  # 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.
153
+ # untouched record must not re-flag it and spam the queue). A field whose upload
154
+ # the concern is watching is left to that upload's hooks (see AfterUpload).
78
155
  def moderate_flag_filtered_fields
79
156
  moderation_filtered_fields.each do |field|
157
+ next if @moderate_watched_uploads&.include?(field)
158
+
80
159
  policy = Moderate.filter_policy_for(self, field)
81
160
  next unless policy.flag?
82
161
  next unless moderation_field_changed_for_commit?(field)
83
162
 
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
163
+ moderate_flag_field(field, policy)
116
164
  end
117
165
  end
118
166
 
167
+ # Just before Active Storage uploads `field`'s files: enqueue the job that
168
+ # classifies them even if this upload fails (see the module comment).
169
+ def moderate_upload_starting(field)
170
+ policy = Moderate.filter_policy_for(self, field)
171
+ return unless policy.flag? && moderation_field_changed_for_commit?(field)
172
+
173
+ (@moderate_guarded_uploads ||= Set.new) << field
174
+ Moderate::ClassifyJob.set(wait: Moderate::ClassifyJob::UPLOAD_WAIT)
175
+ .perform_later(self, field, blob_ids: Moderate::Attachments.blob_ids(moderation_field_value(field)))
176
+ end
177
+
178
+ # The upload returned: classify now if the files are there (the guard job takes
179
+ # them otherwise).
180
+ def moderate_upload_finished(field)
181
+ @moderate_watched_uploads&.delete(field)
182
+ return moderation_field_committed(field) unless @moderate_guarded_uploads&.delete?(field)
183
+
184
+ moderate_flag_field(field, Moderate.filter_policy_for(self, field), guarded: true)
185
+ end
186
+
187
+ # Classify one changed field and file its Flag. Wrapped in `ensure` so the
188
+ # clean-up hook (`moderation_field_committed`) always runs even if
189
+ # classification raises — that hook is what clears the one-shot attachment
190
+ # snapshot.
191
+ #
192
+ # An attachment whose files aren't all on the service yet is never classified
193
+ # here: the after-upload guard job takes it (`guarded:`), or a ClassifyJob that
194
+ # waits for it is enqueued now.
195
+ def moderate_flag_field(field, policy, guarded: false)
196
+ value = moderation_field_value(field)
197
+ attachment = Moderate::Attachments.attachment?(value)
198
+ return if attachment && moderation_value_blank?(value)
199
+
200
+ if attachment && !Moderate::Attachments.uploaded?(value)
201
+ Moderate::ClassifyJob.perform_later(self, field, blob_ids: Moderate::Attachments.blob_ids(value)) unless guarded
202
+ return
203
+ end
204
+
205
+ # ASYNC adapters (remote moderation APIs) never classify inline — a network
206
+ # call in the request's after_commit would stall the response for as long
207
+ # as the provider takes. ClassifyJob re-reads the value at run time (so it
208
+ # always classifies what's actually persisted) and files the Flag through the
209
+ # same Flag.flag! builder.
210
+ if Moderate.config.adapter_async?(policy.adapter)
211
+ blob_ids = (Moderate::Attachments.blob_ids(value) if attachment)
212
+ Moderate::ClassifyJob.perform_later(self, field, blob_ids: blob_ids)
213
+ return
214
+ end
215
+
216
+ return if moderation_value_blank?(value)
217
+ return moderate_classify_attachment(field, policy) if attachment
218
+
219
+ result = Moderate.classify(value, policy: policy)
220
+ return unless result.flagged?
221
+
222
+ moderate_file_flag(field, policy, value, result)
223
+ ensure
224
+ moderation_field_committed(field)
225
+ end
226
+
227
+ # Claim, classify outside any lock, complete (Moderate::Attachments): the guard
228
+ # job and this path never both classify the same files, and a slow adapter never
229
+ # holds this record's row.
230
+ def moderate_classify_attachment(field, policy)
231
+ Moderate::Attachments.classify_once(
232
+ self, field,
233
+ owner: SecureRandom.uuid,
234
+ read: -> { self.class.unscoped.find_by(id: id)&.send(:moderation_field_value, field) },
235
+ classify: ->(value) { Moderate.classify(value, policy: policy) },
236
+ file: ->(value, result) { moderate_file_flag(field, policy, value, result) }
237
+ )
238
+ end
239
+
240
+ def moderate_file_flag(field, policy, value, result)
241
+ Moderate::Flag.flag!(
242
+ flaggable: self,
243
+ field: field,
244
+ # `reported_owner` comes from Moderate::Reportable. A filterable model is
245
+ # usually also reportable; if it isn't — or its owner isn't a person
246
+ # (Moderate.responsible_user) — the owner is simply nil (the flag still
247
+ # lands in the queue, just unattributed).
248
+ owner: Moderate.responsible_user(self),
249
+ source: result.source,
250
+ mode: policy.mode,
251
+ # Cap the stored excerpt so we never balloon a row with a huge body; 500
252
+ # chars is plenty of context for a reviewer.
253
+ excerpt: value.to_s.truncate(500),
254
+ categories: result.categories,
255
+ scores: result.scores,
256
+ # Keep the policy that produced the flag alongside the adapter's raw
257
+ # payload, so the queue can show "flagged by <adapter> under
258
+ # <Class>#<field>" and an auditor can inspect the untouched response.
259
+ context: {
260
+ raw: result.raw,
261
+ policy: { class_name: policy.class_name, field: policy.field, mode: policy.mode.to_s }
262
+ }
263
+ )
264
+ end
265
+
119
266
  # --- Overridable field seam -----------------------------------------------
120
267
  #
121
268
  # 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.
269
+ # columns AND non-column content, without hosts having to re-plumb the common
270
+ # cases. Defaults handle text attributes AND Active Storage attachments; a
271
+ # host overrides them for anything richer (derived values, external blobs).
125
272
 
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.
273
+ # The value to classify for `field`. Default: the attribute reader — which for
274
+ # a `has_one_attached` field returns the `ActiveStorage::Attached` proxy, the
275
+ # natural input for an image adapter (it can read `.record`, `.blob`,
276
+ # `.variant(...)`, or download bytes as it sees fit).
129
277
  def moderation_field_value(field)
130
278
  public_send(field)
131
279
  end
132
280
 
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).
281
+ # Did `field` change on the just-committed save? Defaults, in order:
282
+ # 1. the attachment snapshot taken in before_save (AR dirty tracking can't
283
+ # see attachment writes, and Active Storage drops `attachment_changes`
284
+ # in its own after_commit — hence the one-shot snapshot),
285
+ # 2. ActiveRecord's own dirty tracking (`saved_change_to_attribute?`),
286
+ # 3. `true` for a PORO/ActiveModel object with no dirty tracking at all —
287
+ # the conservative assumption.
288
+ # Override for non-attribute content the defaults can't see.
138
289
  def moderation_field_changed_for_commit?(field)
290
+ return true if @moderate_changed_attachment_fields&.include?(field.to_s)
291
+
139
292
  if respond_to?(:saved_change_to_attribute?)
140
293
  saved_change_to_attribute?(field)
141
294
  elsif respond_to?(:"saved_change_to_#{field}?")
@@ -146,10 +299,42 @@ module Moderate
146
299
  end
147
300
 
148
301
  # 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)
302
+ # Consumes the one-shot attachment snapshot; override-and-super if you track
303
+ # extra per-field state of your own.
304
+ def moderation_field_committed(field)
305
+ @moderate_changed_attachment_fields&.delete(field.to_s)
152
306
  nil
153
307
  end
308
+
309
+ # before_save: record which FILTERED fields have a pending attachment write on
310
+ # this save, and watch the upload of each (AfterUpload). `attachment_changes`
311
+ # only exists on Active Storage models — plain models skip straight through. A
312
+ # removal (`avatar = nil`) is a change with nothing to upload, so nothing to watch.
313
+ def moderate_snapshot_attachment_changes
314
+ return unless respond_to?(:attachment_changes)
315
+
316
+ @moderate_watched_uploads = Set.new
317
+ moderation_filtered_fields.each do |field|
318
+ change = attachment_changes[field]
319
+ next if change.nil?
320
+
321
+ (@moderate_changed_attachment_fields ||= Set.new) << field
322
+ next unless change.respond_to?(:upload)
323
+
324
+ change.extend(AfterUpload) unless change.is_a?(AfterUpload)
325
+ change.moderate_watch(self, field)
326
+ @moderate_watched_uploads << field
327
+ end
328
+ nil # a callback's return value never halts the save (only `throw :abort` does)
329
+ end
330
+
331
+ # Blank check that can see through an `ActiveStorage::Attached` proxy — an
332
+ # attachment with nothing attached must read as "nothing to classify"
333
+ # (Object#blank? can't tell: the proxy is truthy and has no #empty?).
334
+ def moderation_value_blank?(value)
335
+ return !value.attached? if value.respond_to?(:attached?)
336
+
337
+ value.blank?
338
+ end
154
339
  end
155
340
  end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moderate
4
+ # Notices that must not reach anyone before the change they announce is durable.
5
+ #
6
+ # A service changes a record inside a transaction, then queues what to send with
7
+ # `record.after_commit_notice { … }`. The TRANSACTION owns the notice, exactly as it
8
+ # owns a record's after_commit callbacks: the notice is wrapped in an Envelope and
9
+ # enrolled in the current transaction (`connection.add_transaction_record`, the same
10
+ # call Active Record makes for a saved record, on every supported Rails), and
11
+ # the transaction finalizes it — sends it on commit, drops it on rollback or when an
12
+ # exception ends the transaction. Nothing lives on a Ruby instance or on the thread,
13
+ # so it doesn't matter which instance of a row was saved, whether the row was saved
14
+ # at all, or which thread runs next.
15
+ #
16
+ # Notices follow Active Record's after_commit rules, exactly:
17
+ # - with no transaction open, the notice is sent at once;
18
+ # - the notice joins the transaction of the connection it is queued ON. Queue it
19
+ # inside the transaction that saved the record, on that transaction's connection:
20
+ # `after_commit_notice(on: connection)`, where `on:` defaults to the record
21
+ # class's connection at call time. The gem's services capture their model's
22
+ # connection at the top of the transaction, before any save, and pass it to every
23
+ # notice they queue, so a host callback that switches databases or shards
24
+ # mid-save can't move them. A host that calls after_commit_notice itself passes
25
+ # `on:` when it uses `connected_to`;
26
+ # - in nested transactions that join their parent, and in savepoints
27
+ # (`requires_new: true`) under a joinable parent, it moves up and is sent at the
28
+ # OUTERMOST commit; a rolled-back savepoint drops only its own notices;
29
+ # - under a parent that is not joinable (`transaction(joinable: false)`, and the
30
+ # wrapper Rails' transactional tests run each test in), it is sent when the
31
+ # savepoint is released — when after_commit runs too.
32
+ module DeferredNotices
33
+ # A queued notice, in the shape a transaction finalizes: Active Record calls
34
+ # `before_committed!`, then `committed!` or `rolledback!` on every record enrolled
35
+ # in it (and reads `trigger_transactional_callbacks?`, `destroyed?`,
36
+ # `_new_record_before_last_commit` when choosing which instance runs callbacks).
37
+ #
38
+ # @api private
39
+ class Envelope
40
+ attr_accessor :_new_record_before_last_commit
41
+
42
+ def initialize(notice)
43
+ @notice = notice
44
+ @finalized = false
45
+ end
46
+
47
+ def trigger_transactional_callbacks? = true
48
+ def has_transactional_callbacks? = true # rubocop:disable Naming/PredicatePrefix -- Active Record's name
49
+ def destroyed? = false
50
+ def before_committed!; end
51
+
52
+ # Sent once, when the transaction that owns the envelope runs commit callbacks.
53
+ # (Active Record also calls this with should_run_callbacks: false when it is only
54
+ # cleaning up after an exception: nothing is sent then.)
55
+ def committed!(should_run_callbacks: true)
56
+ return if @finalized
57
+
58
+ @finalized = true
59
+ @notice.call if should_run_callbacks
60
+ end
61
+
62
+ def rolledback!(force_restore_state: false, should_run_callbacks: true) # rubocop:disable Lint/UnusedMethodArgument
63
+ @finalized = true
64
+ end
65
+ end
66
+
67
+ # Enroll `notice` in `connection`'s current transaction, or send it now when there
68
+ # is no connection or it has no transaction open.
69
+ #
70
+ # @api private: call `record.after_commit_notice { … }`.
71
+ def self.defer(connection, &notice)
72
+ return notice.call unless connection&.current_transaction&.open?
73
+
74
+ connection.add_transaction_record(Envelope.new(notice))
75
+ nil
76
+ end
77
+
78
+ # The connection a service captures at the top of its transaction, before any save.
79
+ #
80
+ # @api private
81
+ def self.connection_for(model)
82
+ model.lease_connection
83
+ end
84
+
85
+ def after_commit_notice(on: DeferredNotices.connection_for(self.class), &notice)
86
+ DeferredNotices.defer(on, &notice)
87
+ self
88
+ end
89
+ end
90
+ end
@@ -9,7 +9,8 @@ module Moderate
9
9
  # `Moderate::Report#reportable` association. This concern answers the
10
10
  # Trust & Safety questions that polymorphism *can't*:
11
11
  #
12
- # - which fields of the record may be reported (`reportable_fields`)
12
+ # - which fields of the record may be reported (`reportable_fields`), and
13
+ # what humans should call each one (`labels:` / `reportable_field_label`)
13
14
  # - who is *responsible* for the content (`reported_owner`) — the person a
14
15
  # decision's statement of reasons must reach (DSA Art. 17) and who a ban
15
16
  # would apply to
@@ -56,6 +57,15 @@ module Moderate
56
57
  # Empty default = "the whole record is reportable, no specific field."
57
58
  class_attribute :moderation_reportable_fields, instance_writer: false, default: [].freeze
58
59
 
60
+ # The human labels declared next to the fields (`labels: { body: "Chat
61
+ # message" }`), keyed by field name String. Values are Strings or callables
62
+ # (evaluated at read time, so a lambda can call I18n under the CURRENT
63
+ # locale). Same `class_attribute` reasoning as the field list above: it
64
+ # inherits down an STI tree and a subclass redeclaration doesn't mutate the
65
+ # parent. Read through `Moderate.reportable_field_label`, never directly —
66
+ # that's where the i18n and humanize fallbacks live.
67
+ class_attribute :moderation_reportable_field_labels, instance_writer: false, default: {}.freeze
68
+
59
69
  # Self-register in the gem's reportable registry the moment the concern is
60
70
  # included, so `Moderate.reportable_classes` is auto-discovered with NO
61
71
  # manual list to maintain (README: "Reportable classes are auto-discovered
@@ -73,10 +83,54 @@ module Moderate
73
83
  #
74
84
  # reportable_fields :title, :description # writer
75
85
  # reportable_fields # => ["title", "description"]
76
- def reportable_fields(*fields)
77
- self.moderation_reportable_fields = fields.map(&:to_s).freeze if fields.any?
86
+ #
87
+ # `labels:` names each field for humans — what the moderation queue, the
88
+ # statement of reasons, and the appeal page print instead of the raw column
89
+ # name (a moderator reading «Body» on a chat-message report has to guess
90
+ # what was reported). Optional and per field; anything unlabeled falls back
91
+ # to i18n, then to `field.humanize` (see `Moderate.reportable_field_label`).
92
+ #
93
+ # reportable_fields :body, :files, labels: { body: "Chat message", files: "Chat photo" }
94
+ #
95
+ # Labels follow the same "last declaration wins" rule as the fields, and a
96
+ # label for a field you didn't declare raises — a typo'd key would otherwise
97
+ # silently never show up.
98
+ def reportable_fields(*fields, labels: nil)
99
+ if labels && fields.empty?
100
+ raise ArgumentError, "#{name}: `labels:` needs the fields it labels — " \
101
+ "declare them together, e.g. `reportable_fields :body, labels: { body: \"Message\" }`"
102
+ end
103
+
104
+ if fields.any?
105
+ field_names = fields.map(&:to_s).freeze
106
+ self.moderation_reportable_fields = field_names
107
+ self.moderation_reportable_field_labels = normalize_reportable_field_labels(labels, field_names)
108
+ end
109
+
78
110
  moderation_reportable_fields
79
111
  end
112
+
113
+ # The human label for one of this class's reported fields. Class-level sugar
114
+ # for `Moderate.reportable_field_label(self, field)`.
115
+ def reportable_field_label(field)
116
+ Moderate.reportable_field_label(self, field)
117
+ end
118
+
119
+ private
120
+
121
+ def normalize_reportable_field_labels(labels, field_names)
122
+ return {}.freeze if labels.blank?
123
+ raise ArgumentError, "#{name}: `labels:` must be a Hash of field => label" unless labels.respond_to?(:to_h)
124
+
125
+ normalized = labels.to_h.transform_keys(&:to_s)
126
+ unknown = normalized.keys - field_names
127
+ if unknown.any?
128
+ raise ArgumentError, "#{name}: `labels:` names #{unknown.map(&:inspect).join(", ")}, " \
129
+ "not among the reportable fields #{field_names.inspect}"
130
+ end
131
+
132
+ normalized.freeze
133
+ end
80
134
  end
81
135
 
82
136
  # Is `field` one a reporter is allowed to name? Two cases:
@@ -100,6 +154,13 @@ module Moderate
100
154
  self.class.reportable_fields.include?(field_s)
101
155
  end
102
156
 
157
+ # The human label for `field` on this record ("Chat message", not "Body"), or
158
+ # nil for a blank field (a whole-record report has no field to name). Sugar
159
+ # for `Moderate.reportable_field_label(self, field)`.
160
+ def reportable_field_label(field)
161
+ Moderate.reportable_field_label(self, field)
162
+ end
163
+
103
164
  # WHO is responsible for this content — the account a decision's statement of
104
165
  # reasons reaches (DSA Art. 17) and the user a ban would apply to.
105
166
  #
@@ -179,6 +240,25 @@ module Moderate
179
240
  true
180
241
  end
181
242
 
243
+ # The generic content bucket this record is (Report::CONTENT_TYPES: "message",
244
+ # "listing", "user_profile", …), stored on reports filed against it so the queue
245
+ # and the transparency counts can group by kind. nil (the default) leaves the
246
+ # report's content_type empty; a value outside CONTENT_TYPES is stored as "other".
247
+ def moderation_content_type(_field = nil)
248
+ nil
249
+ end
250
+
251
+ # The signed, purpose-scoped token that names this record in a report link
252
+ # (`/reports/new?target=…`), resolved back by Report.locate_signed_reportable.
253
+ # NON-expiring on purpose: it identifies which record to report and grants no
254
+ # capability, and an expiring token changes on every render, which defeats the
255
+ # fragment caching of anything that shows a report link (a chat bubble rendered
256
+ # once per broadcast, for every viewer). Prefer the view helper
257
+ # `moderate_report_target_path(record, field:)` to building URLs by hand.
258
+ def to_moderation_sgid
259
+ to_sgid_param(for: Moderate::Report::SIGNED_GLOBAL_ID_PURPOSE, expires_in: nil)
260
+ end
261
+
182
262
  # Open reports filed against this record, optionally narrowed to one field.
183
263
  # Hosts can use this when they need the actual relation (queue previews,
184
264
  # counters, "already reported" affordances) instead of just a boolean.
@@ -264,7 +344,7 @@ module Moderate
264
344
  return false if owner.nil?
265
345
 
266
346
  if owner.respond_to?(:id) && viewer.respond_to?(:id)
267
- owner.id == viewer.id && owner.class == viewer.class
347
+ owner.id == viewer.id && owner.instance_of?(viewer.class)
268
348
  else
269
349
  owner == viewer
270
350
  end