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,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "label"
4
+
5
+ module Moderate
6
+ # The single return type of every filter adapter: `adapter.classify(value) → Moderate::Result`.
7
+ #
8
+ # This is the gem's content-filtering value object — immutable, frozen at
9
+ # construction. It answers the two questions the rest of the gem asks ("is this
10
+ # allowed?" and "if not, why?") and carries the per-label detail for the
11
+ # moderation queue, the DSA statement of reasons, and the transparency counters.
12
+ #
13
+ # The public surface follows the README's "Content filtering" section verbatim:
14
+ # result.allowed? # => false
15
+ # result.flagged? # => true (the inverse — convenience for the validator)
16
+ # result.categories # => [:hate, :"hate/threatening"] (canonical slugs)
17
+ # result.scores # => { "hate" => 0.97, "hate/threatening" => 0.81 }
18
+ # result.labels # => [#<Moderate::Label ...>, ...]
19
+ # result.source # => "wordlist" / "openai" / your adapter name
20
+ # result.raw # => the untouched provider response (for debugging/audit)
21
+ #
22
+ # Built on `Data.define` (Ruby 3.2+) for a frozen value object. We expose a
23
+ # keyword `.new` whose contract is forgiving: an adapter can hand us either a
24
+ # rich `labels:` array OR the flatter `categories:`/`scores:` shape (the simpler
25
+ # shape a deterministic adapter naturally returns), and we reconcile both into a
26
+ # coherent Result.
27
+ Result = Data.define(:allowed, :labels, :source, :raw) do
28
+ # @param allowed [Boolean, nil] explicit allow/deny. If nil, we infer it
29
+ # from whether any label is flagged (no labels ⇒ allowed).
30
+ # @param labels [Array<Moderate::Label, Hash>] rich per-label verdicts.
31
+ # Hashes are coerced to `Moderate::Label`. Optional.
32
+ # @param categories [Array<String, Symbol>] flat canonical slugs, the simpler
33
+ # shape adapters may return instead of `labels:`. Each becomes a Label
34
+ # (parsing "hate/threatening" → category :hate, subcategory :threatening).
35
+ # @param scores [Hash] slug => 0..1 score, merged onto the labels built
36
+ # from `categories:` (and onto label slugs generally).
37
+ # @param source [String, Symbol] the adapter name that produced this — the
38
+ # value recorded as `Moderate::Flag#source` so the queue shows which backend
39
+ # flagged each item. Defaults to "unknown".
40
+ # @param raw [Object] the untouched provider payload, kept for audit
41
+ # and debugging. Never relied on by the gem's own logic.
42
+ def initialize(allowed: nil, labels: nil, categories: nil, scores: nil, source: nil, raw: nil)
43
+ scores_hash = normalize_scores(scores)
44
+ built_labels = build_labels(labels, categories, scores_hash)
45
+
46
+ # Infer `allowed` when the adapter didn't say: any flagged label ⇒ denied.
47
+ # This lets a deterministic adapter return just `categories: [...]` and have
48
+ # the verdict fall out correctly, without having to compute `allowed` itself.
49
+ resolved_allowed =
50
+ if allowed.nil?
51
+ built_labels.none?(&:flagged)
52
+ else
53
+ allowed ? true : false
54
+ end
55
+
56
+ super(
57
+ allowed: resolved_allowed,
58
+ labels: built_labels.freeze,
59
+ source: (source || "unknown").to_s,
60
+ raw: raw
61
+ )
62
+ end
63
+
64
+ # Convenience builder for the most common deterministic case: nothing matched.
65
+ def self.allowed(source: nil, raw: nil)
66
+ new(allowed: true, labels: [], source: source, raw: raw)
67
+ end
68
+
69
+ def allowed? = allowed
70
+
71
+ # The inverse of `allowed?`. The validator and the `moderates` concern read
72
+ # this; it's spelled out (rather than `!allowed`) because "flagged?" is the
73
+ # word everyone reaches for.
74
+ def flagged? = !allowed
75
+
76
+ # Canonical category slugs as symbols, e.g. [:hate, :"hate/threatening"].
77
+ # Only flagged labels count — an adapter may return a full score map including
78
+ # non-tripping categories, and those shouldn't show up as "the categories this
79
+ # tripped". De-duplicated, order-preserving.
80
+ def categories
81
+ flagged_labels.map { |label| label.slug.to_sym }.uniq
82
+ end
83
+
84
+ # slug => score, e.g. { "hate" => 0.97, "hate/threatening" => 0.81 }. String
85
+ # keys to match OpenAI's wire format and what we persist on the Flag. Skips
86
+ # labels with a nil score (a deterministic adapter may not provide one).
87
+ def scores
88
+ flagged_labels.each_with_object({}) do |label, acc|
89
+ acc[label.slug] = label.score unless label.score.nil?
90
+ end
91
+ end
92
+
93
+ private
94
+
95
+ def flagged_labels
96
+ labels.select(&:flagged)
97
+ end
98
+
99
+ def normalize_scores(scores)
100
+ return {} if scores.nil?
101
+
102
+ # Accept symbol- or string-keyed maps; normalize keys to the slug string so
103
+ # we can look up by a Label's slug regardless of how the adapter keyed them.
104
+ scores.each_with_object({}) do |(key, value), acc|
105
+ acc[key.to_s] = value&.to_f
106
+ end
107
+ end
108
+
109
+ # Reconcile the two accepted shapes (`labels:` and `categories:`+`scores:`)
110
+ # into one frozen array of `Moderate::Label`.
111
+ def build_labels(labels, categories, scores_hash)
112
+ out = []
113
+
114
+ Array(labels).each do |label|
115
+ out << coerce_label(label, scores_hash)
116
+ end
117
+
118
+ Array(categories).each do |category|
119
+ out << label_from_slug(category.to_s, scores_hash)
120
+ end
121
+
122
+ out
123
+ end
124
+
125
+ def coerce_label(label, scores_hash)
126
+ return apply_score(label, scores_hash) if label.is_a?(Moderate::Label)
127
+
128
+ # Allow a plain Hash (e.g. from a deserialized adapter response).
129
+ label_from_hash(label.to_h, scores_hash)
130
+ end
131
+
132
+ # Backfill a Label's score from the scores map when the Label itself carries
133
+ # none — so an adapter can pass `labels:` for structure and `scores:` for
134
+ # confidence as two parallel inputs.
135
+ def apply_score(label, scores_hash)
136
+ return label unless label.score.nil?
137
+
138
+ score = scores_hash[label.slug]
139
+ return label if score.nil?
140
+
141
+ Moderate::Label.new(
142
+ category: label.category, subcategory: label.subcategory,
143
+ score: score, flagged: label.flagged, input: label.input
144
+ )
145
+ end
146
+
147
+ def label_from_hash(hash, scores_hash)
148
+ hash = hash.transform_keys { |k| k.to_s }
149
+ category = hash["category"]
150
+ subcategory = hash["subcategory"]
151
+ slug = subcategory ? "#{category}/#{subcategory}" : category.to_s
152
+
153
+ Moderate::Label.new(
154
+ category: category,
155
+ subcategory: subcategory,
156
+ score: hash.fetch("score", scores_hash[slug]),
157
+ flagged: hash.fetch("flagged", true),
158
+ input: hash.fetch("input", :unknown)
159
+ )
160
+ end
161
+
162
+ # Parse a canonical slug ("hate/threatening" or "hate") into a Label, pulling
163
+ # its score from the scores map if present.
164
+ def label_from_slug(slug, scores_hash)
165
+ category, subcategory = slug.split("/", 2)
166
+
167
+ Moderate::Label.new(
168
+ category: category,
169
+ subcategory: subcategory,
170
+ score: scores_hash[slug],
171
+ flagged: true,
172
+ input: :unknown
173
+ )
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moderate
4
+ module Services
5
+ # IntakeAppeal — persistence path for a DSA Article 20 internal complaint
6
+ # ("appeal") against a moderation decision.
7
+ #
8
+ # Art. 20 requires an internal complaint-handling system that is FREE, by
9
+ # ELECTRONIC means, open for AT LEAST SIX MONTHS after the decision, and decided
10
+ # by a human (not solely automated). This service owns only the *intake* half:
11
+ # validating + persisting the complaint and emitting the `appeal_received`
12
+ # receipt. The human decision lives in ResolveAppeal.
13
+ # See: https://eur-lex.europa.eu/eli/reg/2022/2065/oj (Article 20).
14
+ #
15
+ # The model enforces the legal preconditions (the report must be closed and its
16
+ # appeal window still open) as validations, so a save that violates them returns
17
+ # false with errors — this service doesn't re-implement that, it just runs the
18
+ # surrounding side effects on success.
19
+ #
20
+ # HOST-AGNOSTIC: the appellant may be a User (a logged-in affected party) OR an
21
+ # anonymous notifier (name + email) appealing a public-notice decision. We never
22
+ # assume a User.
23
+ class IntakeAppeal
24
+ # @param appeal [Moderate::Appeal] an unsaved Appeal the caller has populated
25
+ # (reason, source, appellant contact). The caller owns strong-params; we own
26
+ # save + side effects.
27
+ # @param report [Moderate::Report] the decision being appealed.
28
+ # @param appellant [user_class, nil] the complainant, when they're a User.
29
+ def initialize(appeal:, report:, appellant: nil)
30
+ @appeal = appeal
31
+ @appeal.assign_attributes(report: report, appellant: appellant)
32
+ end
33
+
34
+ attr_reader :appeal
35
+
36
+ def save
37
+ return false unless appeal.save
38
+
39
+ deliver_receipt
40
+ audit_intake
41
+ true
42
+ end
43
+
44
+ private
45
+
46
+ # The appellant's receipt ("we got your appeal; a person will review it").
47
+ # Recipient is the User when present, else the lightweight notifier struct,
48
+ # so the host's notify hook addresses both the same way (`recipient.email`).
49
+ def deliver_receipt
50
+ Moderate.notify(
51
+ :appeal_received,
52
+ subject: appeal,
53
+ actor: appeal.appellant,
54
+ recipients: [appellant_recipient].compact,
55
+ payload: {
56
+ report_id: appeal.report_id,
57
+ source: appeal.source,
58
+ summary: "New appeal on Report ##{appeal.report_id}"
59
+ }
60
+ )
61
+ end
62
+
63
+ def audit_intake
64
+ Moderate.audit(
65
+ :appeal_received,
66
+ subject: appeal,
67
+ actor: appeal.appellant,
68
+ payload: {
69
+ appeal_id: appeal.id,
70
+ report_id: appeal.report_id,
71
+ source: appeal.source,
72
+ summary: "Appeal ##{appeal.id} filed on Report ##{appeal.report_id}"
73
+ }.compact
74
+ )
75
+ end
76
+
77
+ # Prefer the User; fall back to a non-User recipient carrying just the email/
78
+ # name an anonymous appellant supplied. Reuses the same lightweight-recipient
79
+ # contract as IntakeNotice (responds to email/name only) so host mailers don't
80
+ # special-case appeals.
81
+ def appellant_recipient
82
+ return appeal.appellant if appeal.appellant
83
+ return nil if appeal.appellant_email.blank?
84
+
85
+ IntakeNotice::NotifierRecipient.new(appeal.appellant_email, appeal.appellant_name)
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moderate
4
+ module Services
5
+ # IntakeNotice — persistence path for a *public* DSA Article 16 notice.
6
+ #
7
+ # This is the regulator-facing intake: the "Report illegal content (EU)" form
8
+ # you see at the bottom of X / YouTube / Reddit, open to ANYONE (not just
9
+ # logged-in users), by electronic means, with a confirmation of receipt. It is
10
+ # legally distinct from the in-app "Report" button:
11
+ # - it carries a `legal_reason` from the DSA statement-of-reasons taxonomy
12
+ # (the law's vocabulary) rather than a community `category` (your rules);
13
+ # - the notifier may be anonymous (a name+email, not a User);
14
+ # - it requires the exact electronic location (URL) of the content — Art.16(2)(b);
15
+ # - it requires a good-faith attestation — Art.16(2)(d).
16
+ # See: https://eur-lex.europa.eu/eli/reg/2022/2065/oj (Article 16).
17
+ #
18
+ # A notice is NOT a fourth table: it's a `Moderate::Report` with
19
+ # `intake_kind: "dsa"`, so it shares the same queue, evidence snapshot, appeal
20
+ # window, statement-of-reasons path, and Art. 24 transparency counters as an
21
+ # in-app report. One queue, one decision workflow, two front doors. This service
22
+ # builds that notice-kind Report and hands it to IntakeReport for the shared
23
+ # persistence + side effects, then emits the notice-specific `notice_received`
24
+ # event whose delivery boolean gates the Art. 16(4) "confirmation of receipt".
25
+ #
26
+ # HOST-AGNOSTIC: no concrete content type is named. The URL is resolved to a
27
+ # reportable record (if the host can) by the Report model's snapshot logic; this
28
+ # service only owns intake orchestration and the legal-email gating.
29
+ class IntakeNotice
30
+ # @param attributes [Hash] the public-form attributes, already strong-param'd
31
+ # by the controller: notifier_name, notifier_email, good_faith_confirmed,
32
+ # legal_reason, legal_country_code, subject_url(s), content_type, message
33
+ # (the substantiated explanation), anonymous, reported_account_identifier.
34
+ # @param reporter [user_class, nil] the submitter IF they happened to be
35
+ # logged in (the form is public, so usually nil).
36
+ def initialize(attributes:, reporter: nil)
37
+ # Force the DSA shape regardless of what the form posted: a public notice is
38
+ # always intake_kind "dsa". We default the community `category` to a neutral
39
+ # "illegal_content" because the column is NOT NULL and a notice's real
40
+ # taxonomy lives in `legal_reason` — the category is just the bucket that
41
+ # keeps a notice in the same queue as community reports.
42
+ @report = Moderate::Report.new(attributes)
43
+ @report.assign_attributes(
44
+ intake_kind: "dsa",
45
+ category: @report.category.presence || "illegal_content"
46
+ )
47
+ @report.skip_received_notice = true
48
+ @reporter = reporter
49
+ end
50
+
51
+ attr_reader :report
52
+
53
+ def save
54
+ # Reuse the shared intake (save + acknowledge! + audit). We pass the report
55
+ # straight through — it already carries the notice attributes and the forced
56
+ # DSA shape. The reporter is the actor when present (a logged-in submitter);
57
+ # for a truly anonymous notice the actor is nil, which the event envelope
58
+ # handles (system/no-actor events are normal).
59
+ intake = IntakeReport.new(
60
+ report: report,
61
+ reporter: reporter,
62
+ reportable: report.reportable,
63
+ reported_field: report.reported_field,
64
+ actor: reporter
65
+ )
66
+ return false unless intake.save
67
+
68
+ deliver_confirmation_of_receipt
69
+ true
70
+ end
71
+
72
+ private
73
+
74
+ attr_reader :reporter
75
+
76
+ # DSA Art. 16(4): "the provider shall, without undue delay, send to that
77
+ # individual or entity a confirmation of receipt of the notice."
78
+ #
79
+ # We send it to the NOTIFIER (who is often not a User — an anonymous person
80
+ # with just an email), distinct from IntakeReport's in-app receipt. The notify
81
+ # hook returns a "delivered" boolean: when it's truthy we stamp
82
+ # `decision_notified_at`... no — we stamp nothing here, because the durable
83
+ # legal proof of *receipt* is `acknowledged_at` (already set by IntakeReport).
84
+ # The boolean instead lets the controller fall back to an on-screen receipt
85
+ # (the form shows a reference number) when the host hasn't wired a mailer —
86
+ # that's the whole reason `Moderate.notify` returns delivered/undelivered
87
+ # rather than nothing. See docs/notifications.md and docs/dsa-notice-form.md.
88
+ #
89
+ # The recipient is a lightweight notifier struct (responds to email/name) when
90
+ # the submitter isn't a User, so the host's `notify` hook can email it the same
91
+ # way it emails a real user — the recipes guard with `respond_to?(:email)`.
92
+ def deliver_confirmation_of_receipt
93
+ return false if report.notifier_email.blank?
94
+
95
+ Moderate.notify(
96
+ :notice_received,
97
+ subject: report,
98
+ actor: reporter,
99
+ recipients: [notifier_recipient],
100
+ payload: {
101
+ legal_reason: report.legal_reason,
102
+ legal_country_code: report.legal_country_code,
103
+ subject_url: report.subject_url,
104
+ # Redaction-safe admin one-liner. We deliberately keep host content out
105
+ # of it — just the legal ground and an opaque pointer to the record.
106
+ summary: "New DSA notice (#{report.legal_reason || 'illegal_content'}) — Report ##{report.id}"
107
+ }
108
+ )
109
+ end
110
+
111
+ # A non-User recipient the host's mailer can address. We prefer the real
112
+ # reporter (a User) when the submitter was logged in; otherwise we build a
113
+ # minimal value object that quacks like a recipient (responds to `email`,
114
+ # `name`) — documented in docs/notifications.md as the "lightweight recipient"
115
+ # for anonymous DSA notifiers.
116
+ def notifier_recipient
117
+ return report.reporter if report.reporter
118
+
119
+ NotifierRecipient.new(report.notifier_email, report.notifier_name)
120
+ end
121
+
122
+ # The anonymous-notifier recipient. Intentionally tiny and NOT a User: the
123
+ # host's notify hook reaches `recipient.email` / `recipient.name` and nothing
124
+ # else. Frozen value object.
125
+ NotifierRecipient = Data.define(:email, :name) do
126
+ # Mirror the User-ish reader some host mailers reach for, so a notifier and a
127
+ # User are interchangeable at the `recipient.email` call site.
128
+ def display_name = name.to_s.empty? ? email : name
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moderate
4
+ module Services
5
+ # IntakeReport — the single persistence path for an *in-app* community report
6
+ # (a logged-in actor tapping "Report" on a piece of content or another user).
7
+ #
8
+ # WHY a service object and not just `Report.create`:
9
+ # - Intake is more than a save. After the row commits we must (a) acknowledge
10
+ # receipt, (b) emit the `report_received` event (the reporter's receipt +
11
+ # the admin ping), and (c) write the immutable audit record. Keeping all of
12
+ # that in one object means the model stays a plain validating record and the
13
+ # side effects live in exactly one place — easy to test, impossible to
14
+ # accidentally skip from a second call site.
15
+ # - The same shape is reused by the public DSA notice path (see IntakeNotice),
16
+ # which delegates here once it has assembled a notice-kind Report. One intake,
17
+ # two front doors.
18
+ #
19
+ # This object is HOST-AGNOSTIC: it never references a concrete content type. The
20
+ # `has_reportable_content` is any `Moderate::Reportable` record (polymorphic), the actor is
21
+ # whatever `Moderate.user_class` resolves to, and notification/audit go through
22
+ # the configured hooks — never a hard-wired mailer.
23
+ class IntakeReport
24
+ # @param report [Moderate::Report] an unsaved Report the caller has already
25
+ # populated (category, message, etc.). Letting the caller build the record
26
+ # keeps this service free of the (host-specific) strong-params shape — the
27
+ # controller/macro decides which attributes are permitted; we own the save +
28
+ # side effects.
29
+ # @param reporter [user_class, nil] who filed it. nil for an anonymous public
30
+ # notice (the DSA path), present for an in-app report.
31
+ # @param reportable [Moderate::Reportable, nil] the reported content/record.
32
+ # @param reported_field [String, Symbol, nil] which field was reported.
33
+ # @param actor [user_class, nil] who triggered the intake for the audit/event
34
+ # envelope (defaults to the reporter — they're the same person for an in-app
35
+ # report; a public notice may have no actor).
36
+ def initialize(report:, reporter: nil, reportable: nil, reported_field: nil, actor: :reporter)
37
+ @report = report
38
+ @report.assign_attributes(
39
+ reporter: reporter,
40
+ reportable: reportable,
41
+ reported_field: reported_field&.to_s
42
+ )
43
+ # ":reporter" sentinel means "use the reporter as the actor" — the common
44
+ # case — while still letting a caller pass `actor: nil` explicitly.
45
+ @actor = actor == :reporter ? reporter : actor
46
+ end
47
+
48
+ attr_reader :report
49
+
50
+ # Persist + run side effects. Returns true on success, false if validation
51
+ # failed (the report carries its errors, Rails-conventionally) so a controller
52
+ # can `if intake.save ... else render :new` exactly like a bare model save.
53
+ def save
54
+ return false unless report.save
55
+
56
+ # DSA Art. 16(4): the provider must confirm receipt "without undue delay."
57
+ # `acknowledge!` stamps `acknowledged_at` — the durable, on-record proof of
58
+ # receipt — BEFORE we attempt the (best-effort, possibly-undelivered) email,
59
+ # so the legal obligation is met by the database fact, not by a mailer that
60
+ # might not be wired. See: https://eur-lex.europa.eu/eli/reg/2022/2065/oj
61
+ report.acknowledge!
62
+
63
+ deliver_receipt
64
+ audit_intake
65
+ true
66
+ end
67
+
68
+ private
69
+
70
+ attr_reader :actor
71
+
72
+ # The reporter's receipt. We only attempt it when there's somewhere to send it
73
+ # (a notifier_email) — an in-app reporter without a contact email simply gets
74
+ # the in-app acknowledgement instead. `Moderate.notify` returns a "delivered"
75
+ # boolean; we don't gate anything on it here (the durable receipt is
76
+ # `acknowledged_at`, set above), but the recipient list is still resolved so
77
+ # the host's single notify hook can email AND ping admins from one event.
78
+ def deliver_receipt
79
+ return if report.skip_received_notice
80
+ return if recipient_email.blank?
81
+
82
+ Moderate.notify(
83
+ :report_received,
84
+ subject: report,
85
+ actor: actor,
86
+ recipients: [report.reporter].compact,
87
+ payload: {
88
+ category: report.category,
89
+ intake_kind: report.intake_kind,
90
+ # `:summary` is the contract every event carries — a redaction-safe,
91
+ # ready-to-send one-liner for the admin Telegram ping (docs/notifications.md).
92
+ summary: "New #{report.category} report on #{reportable_label}"
93
+ }
94
+ )
95
+ end
96
+
97
+ # Append-only audit of the intake itself (separate from the notify hook, which
98
+ # is for humans). `Moderate.audit` swallows its own errors, so a broken audit
99
+ # sink can never roll back an accepted report.
100
+ def audit_intake
101
+ Moderate.audit(
102
+ :report_received,
103
+ subject: report,
104
+ actor: actor,
105
+ payload: {
106
+ report_id: report.id,
107
+ reportable_type: report.reportable_type,
108
+ reportable_id: report.reportable_id,
109
+ reported_user_id: report.reported_user_id,
110
+ category: report.category,
111
+ intake_kind: report.intake_kind,
112
+ summary: "Report ##{report.id} filed (#{report.category})"
113
+ }.compact
114
+ )
115
+ end
116
+
117
+ def recipient_email
118
+ report.notifier_email
119
+ end
120
+
121
+ # The reportable's own human label, or a neutral fallback — NEVER a host-
122
+ # specific string. The Reportable concern supplies `moderation_label`.
123
+ def reportable_label
124
+ if report.reportable.respond_to?(:moderation_label)
125
+ report.reportable.moderation_label
126
+ else
127
+ [report.reportable_type, report.reportable_id].compact.join(" #")
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moderate
4
+ module Services
5
+ # ResolveAppeal — the human decision on a DSA Article 20 appeal, behind
6
+ # `appeal.uphold!` / `appeal.reject!`.
7
+ #
8
+ # Art. 20 demands appeals be decided "in a timely, non-discriminatory, diligent
9
+ # and non-arbitrary manner" and crucially "NOT SOLELY ON THE BASIS OF AUTOMATED
10
+ # MEANS." That last clause is why there is no auto-decide path here at all:
11
+ # `uphold!`/`reject!` REQUIRE a `by:` moderator and a `note:` — a human and a
12
+ # reason — and the model column for the moderator (`resolved_by`) makes the human
13
+ # decider part of the permanent record.
14
+ # See: https://eur-lex.europa.eu/eli/reg/2022/2065/oj (Article 20).
15
+ #
16
+ # Same atomic discipline as ResolveReport: the transition runs under a row lock,
17
+ # re-checks `open?` inside the lock (so two moderators can't both decide the same
18
+ # appeal), and the notification happens after the lock releases.
19
+ #
20
+ # NOTE on enforcement: upholding an appeal means the ORIGINAL decision was wrong
21
+ # and should be reversed (Art. 20 outcomes must be acted on). Reversal is
22
+ # host-specific — re-publishing content, lifting a ban — and the gem can't know
23
+ # how to undo an arbitrary host action. So this service records the upheld
24
+ # outcome and emits `appeal_decision`; the host wires the actual reversal off
25
+ # that event (or off its `audit` hook). We document this rather than pretend a
26
+ # generic "un-remove" exists.
27
+ class ResolveAppeal
28
+ def initialize(appeal, by:)
29
+ @appeal = appeal
30
+ @moderator = by
31
+ end
32
+
33
+ def uphold!(note:)
34
+ transition!("upheld", note)
35
+ end
36
+
37
+ def reject!(note:)
38
+ transition!("rejected", note)
39
+ end
40
+
41
+ private
42
+
43
+ attr_reader :appeal, :moderator
44
+
45
+ def transition!(status, note)
46
+ note = require_note!(note)
47
+
48
+ appeal.with_lock do
49
+ appeal.reload
50
+
51
+ # A decided appeal is immutable — re-check inside the lock so a concurrent
52
+ # second decision bails cleanly instead of overwriting the first.
53
+ unless appeal.open?
54
+ appeal.errors.add(:base, already_closed_message)
55
+ raise ActiveRecord::RecordInvalid, appeal
56
+ end
57
+
58
+ appeal.update!(
59
+ status: status,
60
+ resolution_note: note,
61
+ resolved_by: moderator,
62
+ resolved_at: Time.now
63
+ )
64
+
65
+ audit_decision(status, note)
66
+ end
67
+
68
+ deliver_decision_notice(status)
69
+ appeal
70
+ end
71
+
72
+ # Inform the complainant of the appeal outcome and remaining redress (out-of-
73
+ # court dispute settlement / judicial — copy is the host's, it names the
74
+ # jurisdiction). We stamp `decision_notified_at` only when the hook reported a
75
+ # delivery, so the timestamp reflects an actual communication.
76
+ def deliver_decision_notice(status)
77
+ delivered = Moderate.notify(
78
+ :appeal_decision,
79
+ subject: appeal,
80
+ actor: moderator,
81
+ recipients: [appellant_recipient].compact,
82
+ payload: {
83
+ appeal_id: appeal.id,
84
+ report_id: appeal.report_id,
85
+ status: status,
86
+ reason: appeal.resolution_note,
87
+ summary: "Decision on appeal for Report ##{appeal.report_id}: #{status}"
88
+ }.compact
89
+ )
90
+
91
+ appeal.update_column(:decision_notified_at, Time.now) if delivered
92
+ end
93
+
94
+ def audit_decision(status, note)
95
+ Moderate.audit(
96
+ :appeal_decision,
97
+ subject: appeal,
98
+ actor: moderator,
99
+ payload: {
100
+ appeal_id: appeal.id,
101
+ report_id: appeal.report_id,
102
+ status: status,
103
+ note: note,
104
+ summary: "Appeal ##{appeal.id} #{status} by moderator"
105
+ }.compact
106
+ )
107
+ end
108
+
109
+ # The complainant: a User when present, else the lightweight notifier struct.
110
+ def appellant_recipient
111
+ return appeal.appellant if appeal.appellant
112
+ return nil if appeal.appellant_email.blank?
113
+
114
+ IntakeNotice::NotifierRecipient.new(appeal.appellant_email, appeal.appellant_name)
115
+ end
116
+
117
+ def require_note!(note)
118
+ normalized = note.to_s.strip
119
+ return normalized unless normalized.empty?
120
+
121
+ appeal.errors.add(:resolution_note, :blank)
122
+ raise ActiveRecord::RecordInvalid, appeal
123
+ end
124
+
125
+ def already_closed_message
126
+ if defined?(I18n)
127
+ I18n.t("moderate.errors.appeal_already_closed", default: "This appeal has already been decided.")
128
+ else
129
+ "This appeal has already been decided."
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end