Class: Moderate::Services::ResolveReport

Inherits:
Object
  • Object
show all
Defined in:
lib/moderate/services/resolve_report.rb

Overview

ResolveReport — the audited decision engine behind ‘report.resolve!` / `report.dismiss!`.

This is where a moderator’s decision actually happens, and it’s the most legally-loaded path in the gem, so it’s built defensively:

1. ATOMIC + IDEMPOTENT. The whole transition runs inside `report.with_lock`
   (a SELECT ... FOR UPDATE row lock). Two moderators clicking "resolve" at
   once must not both run enforcement (double-ban, double-remove) — the lock
   serializes them, and we RE-CHECK `open?` *inside* the lock after reload so
   the second one sees the closed record and bails with a clean error rather
   than re-applying actions.

2. A NOTE IS MANDATORY. DSA Art. 17 requires a "clear and specific statement
   of reasons"; the moderator's note is the human-readable ground. No note,
   no decision — we raise RecordInvalid with the note error attached.
   See: https://eur-lex.europa.eu/eli/reg/2022/2065/oj (Article 17).

3. ENFORCEMENT IS HOST-AGNOSTIC. Content removal calls the reportable's own
   `remove_reported_field!` (the host decides what "remove" means for its
   content); a ban calls `Moderate.apply_ban` → the host's `ban_handler`
   (the host decides what "banned" means). The gem never reaches into a
   host's domain — it only invokes the contracts.

4. TWO DECISION EVENTS, ON PURPOSE. `report_decision` tells the *reporter*
   "we handled it" (Art. 16(5)); `affected_user_decision` gives the *content
   owner* the Art. 17 statement of reasons (action taken, ground, automated-
   means disclosure, appeal path). Different people, different rights — see
   docs/notifications.md ("Why two decision events").

Instance Method Summary collapse

Constructor Details

#initialize(report, by:) ⇒ ResolveReport

Returns a new instance of ResolveReport.



35
36
37
38
# File 'lib/moderate/services/resolve_report.rb', line 35

def initialize(report, by:)
  @report = report
  @moderator = by
end

Instance Method Details

#dismiss!(note:) ⇒ Object

Dismiss (no violation found). Still requires a note (Art. 17 applies to the reporter’s right to know the outcome too) and still opens an appeal window —the reporter can appeal a dismissal.



60
61
62
63
# File 'lib/moderate/services/resolve_report.rb', line 60

def dismiss!(note:)
  note = require_note!(note)
  transition!("dismissed", note: note, actions: { resolution_basis: "no_violation" })
end

#resolve!(note:, remove_content: false, ban_user: false, resolution_basis: "terms") ⇒ Object

Resolve WITH action (the report was valid; we acted on the content/account). ‘resolution_basis` records the legal/contractual ground bucket; it’s validated against the migration’s check-constraint list by the model.



43
44
45
46
47
48
49
50
51
52
53
54
55
# File 'lib/moderate/services/resolve_report.rb', line 43

def resolve!(note:, remove_content: false, ban_user: false, resolution_basis: "terms")
  note = require_note!(note)
  actions = {
    remove_content: truthy?(remove_content),
    ban_user: truthy?(ban_user),
    resolution_basis: resolution_basis.to_s.strip.presence || "terms"
  }

  transition!("actioned", note: note, actions: actions) do
    remove_reported_content! if actions[:remove_content]
    ban_reported_user!(note: note) if actions[:ban_user]
  end
end