moderate 1.0.0.beta2 → 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 (69) 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 +654 -52
  7. data/CLAUDE.md +1 -1
  8. data/README.md +147 -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/db/migrate/20261018200000_add_pending_key_to_moderate_flags.rb +139 -0
  21. data/db/migrate/20261018210000_add_retention_and_classifications_to_moderate_tables.rb +103 -0
  22. data/docs/chats.md +140 -0
  23. data/docs/compliance.md +92 -3
  24. data/docs/configuration.md +30 -2
  25. data/docs/dsa-notice-form.md +1 -1
  26. data/docs/madmin.md +44 -14
  27. data/docs/notifications.md +17 -4
  28. data/examples/aws_rekognition_adapter.rb +6 -6
  29. data/examples/openai_moderation_adapter.rb +1 -1
  30. data/examples/regexp_pattern_adapter.rb +68 -0
  31. data/gemfiles/{rails_7.1.gemfile → rails_8.0.gemfile} +8 -6
  32. data/gemfiles/rails_8.1.gemfile +6 -5
  33. data/lib/generators/moderate/install_generator.rb +25 -0
  34. data/lib/generators/moderate/templates/create_moderate_tables.rb.erb +17 -2
  35. data/lib/generators/moderate/templates/initializer.rb +9 -15
  36. data/lib/generators/moderate/views_generator.rb +3 -3
  37. data/lib/moderate/attachments.rb +137 -0
  38. data/lib/moderate/configuration.rb +87 -12
  39. data/lib/moderate/engine.rb +86 -25
  40. data/lib/moderate/event.rb +21 -0
  41. data/lib/moderate/filters/base.rb +1 -0
  42. data/lib/moderate/filters/wordlist.rb +5 -2
  43. data/lib/moderate/jobs/classify_job.rb +96 -16
  44. data/lib/moderate/label.rb +2 -2
  45. data/lib/moderate/macros.rb +8 -3
  46. data/lib/moderate/models/appeal.rb +10 -1
  47. data/lib/moderate/models/block.rb +23 -19
  48. data/lib/moderate/models/classification.rb +24 -0
  49. data/lib/moderate/models/concerns/actor.rb +17 -2
  50. data/lib/moderate/models/concerns/content_filterable.rb +181 -55
  51. data/lib/moderate/models/concerns/deferred_notices.rb +90 -0
  52. data/lib/moderate/models/concerns/reportable.rb +84 -4
  53. data/lib/moderate/models/flag.rb +182 -19
  54. data/lib/moderate/models/report.rb +180 -16
  55. data/lib/moderate/result.rb +3 -10
  56. data/lib/moderate/retention.rb +241 -0
  57. data/lib/moderate/services/intake_notice.rb +10 -10
  58. data/lib/moderate/services/intake_report.rb +42 -13
  59. data/lib/moderate/services/resolve_appeal.rb +8 -2
  60. data/lib/moderate/services/resolve_flag.rb +183 -35
  61. data/lib/moderate/services/resolve_report.rb +288 -35
  62. data/lib/moderate/text_validator.rb +15 -14
  63. data/lib/moderate/version.rb +1 -1
  64. data/lib/moderate.rb +337 -17
  65. data/lib/tasks/moderate.rake +23 -0
  66. metadata +22 -14
  67. data/gemfiles/rails_7.2.gemfile +0 -36
  68. data/lib/moderate/text.rb +0 -38
  69. data/lib/moderate/word_list.rb +0 -90
@@ -36,13 +36,68 @@ module Moderate
36
36
  # ACTIVE STORAGE ATTACHMENTS work out of the box: `moderates :avatar, with:
37
37
  # :your_image_adapter, mode: :flag` on a `has_one_attached :avatar` model needs
38
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
39
+ # Storage drops `attachment_changes` in its own after_commit — so the concern
40
40
  # snapshots "these filtered attachments changed" in a before_save and consumes
41
41
  # the snapshot at commit time. The overridable seam below still exists for
42
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.
43
65
  module ContentFilterable
44
66
  extend ActiveSupport::Concern
45
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
+
46
101
  included do
47
102
  # The set of filtered field names (Strings), inherited via class_attribute so
48
103
  # STI subclasses keep their parent's filtered fields. Accumulates across
@@ -73,6 +128,10 @@ module Moderate
73
128
 
74
129
  # :block enforcement — a validation. We deliberately skip :flag fields here
75
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.
76
135
  def moderate_blocked_fields_must_be_allowed
77
136
  moderation_filtered_fields.each do |field|
78
137
  policy = Moderate.filter_policy_for(self, field)
@@ -80,6 +139,7 @@ module Moderate
80
139
 
81
140
  value = moderation_field_value(field)
82
141
  next if moderation_value_blank?(value)
142
+ next errors.add(field, :upload_not_finished) if Moderate::Attachments.upload_unfinished?(value)
83
143
 
84
144
  result = Moderate.classify(value, policy: policy)
85
145
  errors.add(field, :objectionable_content) if result.flagged?
@@ -90,59 +150,117 @@ module Moderate
90
150
  # (inline for synchronous adapters; via Moderate::ClassifyJob for async ones).
91
151
  #
92
152
  # 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.
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).
97
155
  def moderate_flag_filtered_fields
98
156
  moderation_filtered_fields.each do |field|
157
+ next if @moderate_watched_uploads&.include?(field)
158
+
99
159
  policy = Moderate.filter_policy_for(self, field)
100
160
  next unless policy.flag?
101
161
  next unless moderation_field_changed_for_commit?(field)
102
162
 
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
163
+ moderate_flag_field(field, policy)
164
+ end
165
+ end
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
145
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
+ )
146
264
  end
147
265
 
148
266
  # --- Overridable field seam -----------------------------------------------
@@ -162,8 +280,8 @@ module Moderate
162
280
 
163
281
  # Did `field` change on the just-committed save? Defaults, in order:
164
282
  # 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),
283
+ # see attachment writes, and Active Storage drops `attachment_changes`
284
+ # in its own after_commit — hence the one-shot snapshot),
167
285
  # 2. ActiveRecord's own dirty tracking (`saved_change_to_attribute?`),
168
286
  # 3. `true` for a PORO/ActiveModel object with no dirty tracking at all —
169
287
  # the conservative assumption.
@@ -188,18 +306,26 @@ module Moderate
188
306
  nil
189
307
  end
190
308
 
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.
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.
194
313
  def moderate_snapshot_attachment_changes
195
- return true unless respond_to?(:attachment_changes)
314
+ return unless respond_to?(:attachment_changes)
196
315
 
316
+ @moderate_watched_uploads = Set.new
197
317
  moderation_filtered_fields.each do |field|
198
- next unless attachment_changes.key?(field)
318
+ change = attachment_changes[field]
319
+ next if change.nil?
199
320
 
200
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
201
327
  end
202
- true # never halt the save chain
328
+ nil # a callback's return value never halts the save (only `throw :abort` does)
203
329
  end
204
330
 
205
331
  # Blank check that can see through an `ActiveStorage::Attached` proxy — an
@@ -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