Class: Moderate::Block

Inherits:
ApplicationRecord show all
Defined in:
lib/moderate/models/block.rb

Overview

The bidirectional safety edge between two users — the table behind every “block” feature and behind ‘Moderate.blocked_ids_for`.

A block is DIRECTED in the data (‘blocker` → `blocked`) but BIDIRECTIONAL in effect: once either side blocks, neither should see or reach the other. That asymmetry-in-storage / symmetry-in-meaning is the whole subtlety of blocking, and it lives in exactly one place — the `related_user_ids` SSOT query below —so search, messaging, profiles, and feeds never hand-roll their own block SQL (and never get the direction half-right, which is the classic blocking bug).

Apple Guideline 1.2© and Google Play’s UGC policy both require an in-app way to block abusive users; this model is the mechanism. Apple: developer.apple.com/app-store/review/guidelines/#user-generated-content Google: support.google.com/googleplay/android-developer/answer/9876937

Class Method Summary collapse

Class Method Details

.block!(blocker:, blocked:) ⇒ Object

Idempotent block. Creating the same edge twice is a no-op that returns the existing record, so callers never have to check first. Everything runs in one transaction so the audit entry and the on_block side effect commit atomically with the row; the notify fires AFTER the transaction so a slow/failing mailer can’t roll back the block itself.

On a real (new) block we:

1. fire the host's `on_block` side-effect hook (e.g. tear down a pending
   invite, leave a shared room) — host-defined, no-op by default;
2. audit "block created" with both ids;
3. notify `:user_blocked`.

Re-blocking an existing edge skips all three (nothing actually changed).

Raises:

  • (ArgumentError)


57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
# File 'lib/moderate/models/block.rb', line 57

def self.block!(blocker:, blocked:)
  raise ArgumentError, "blocker is required" if blocker.blank?
  raise ArgumentError, "blocked is required" if blocked.blank?

  # Self-block is a NO-OP, not an exception. Blocking yourself is meaningless, and
  # a UI that wires a generic "block this user" affordance shouldn't have to guard
  # the degenerate "block myself" case — so we return an UNPERSISTED, invalid
  # record (carrying the `cannot_block_self` validation error for inspection)
  # rather than raising. No row is written, no on_block/audit/notify fires. (The
  # DB CHECK constraint `moderate_blocks_no_self_block` is the belt-and-suspenders
  # backstop if a caller bypasses this path.)
  if blocker.id.present? && blocker.id == blocked.id
    block = new(blocker: blocker, blocked: blocked)
    block.valid? # populate errors[:blocked] with the self-block message
    return block
  end

  block = nil
  created = false

  transaction do
    block = find_or_initialize_by(blocker: blocker, blocked: blocked)
    created = block.new_record?
    block.save! if created

    if created
      # Side-effect hook FIRST, inside the transaction: if the host's on_block
      # raises, the whole block rolls back rather than leaving a half-applied
      # state (edge saved but invite not torn down).
      on_block_payload = audit_payload_from_on_block(
        Moderate.run_on_block(blocker: blocker, blocked: blocked, at: block.created_at)
      )

      Moderate.audit(
        name: :user_blocked,
        subject: block,
        actor: blocker,
        payload: on_block_payload.merge(
          blocker_id: blocker.id,
          blocked_id: blocked.id,
          summary: "user #{blocker.id} blocked user #{blocked.id}"
        )
      )
    end
  end

  # Notify outside the transaction (see class doc): notify must never be able to
  # roll back the action it's announcing.
  if created
    Moderate.notify(
      :user_blocked,
      subject: block,
      actor: blocker,
      payload: { blocker_id: blocker.id, blocked_id: blocked.id, summary: "user #{blocker.id} blocked user #{blocked.id}" }
    )
  end

  block
end

THE source of truth for “everyone this user is on a block edge with” — both the people they blocked AND the people who blocked them (bidirectional). This is what ‘Moderate.blocked_ids_for` delegates to, and what hosts use to hide blocked pairs from each other everywhere:

Post.where.not(user_id: Moderate.blocked_ids_for(current_user))  # via the facade

Returns an AR relation of user ids (NOT a loaded array) so callers can compose it into a ‘where.not(user_id: …)` subquery that runs entirely in the database — no N user ids round-tripped into Ruby just to be sent back as a giant IN list.

The UNION is the cleanest portable way to express “blocked_id where I’m the blocker, OR blocker_id where I’m the blocked” as a single id set; we build it against the model’s OWN ‘quoted_table_name` (not a hard-coded “moderate_blocks”) so a host that renames/prefixes the table still gets correct SQL.



162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
# File 'lib/moderate/models/block.rb', line 162

def self.related_user_ids(user)
  user_model = Moderate.user_class
  return user_model.none.select(:id) if user.blank?

  user_table = user_model.quoted_table_name
  user_primary_key = connection.quote_column_name(user_model.primary_key)
  block_table = quoted_table_name

  user_model
    .where(
      "#{user_table}.#{user_primary_key} IN (
        SELECT blocked_id FROM #{block_table} WHERE blocker_id = :user_id
        UNION
        SELECT blocker_id FROM #{block_table} WHERE blocked_id = :user_id
      )",
      user_id: user.id
    )
    .select(:id)
end

.unblock!(blocker:, blocked:) ⇒ Object

Remove a block. Returns false if there was nothing to remove (already unblocked), true otherwise — so it’s safe to call unconditionally.



119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
# File 'lib/moderate/models/block.rb', line 119

def self.unblock!(blocker:, blocked:)
  block = find_by(blocker: blocker, blocked: blocked)
  return false if block.blank?

  transaction do
    block.destroy!
    Moderate.audit(
      name: :user_unblocked,
      subject: block,
      actor: blocker,
      payload: {
        blocker_id: blocker.id,
        blocked_id: blocked.id,
        summary: "user #{blocker.id} unblocked user #{blocked.id}"
      }
    )
  end

  Moderate.notify(
    :user_unblocked,
    subject: block,
    actor: blocker,
    payload: { blocker_id: blocker.id, blocked_id: blocked.id, summary: "user #{blocker.id} unblocked user #{blocked.id}" }
  )

  true
end