Module: Collavre::Creative::RealtimeBroadcastable

Extended by:
ActiveSupport::Concern
Included in:
Collavre::Creative
Defined in:
app/models/collavre/creative/realtime_broadcastable.rb

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.broadcast_batch_created(creatives) ⇒ Object

Enqueue a single batch job for multiple created creatives (e.g. MarkdownImporter). Processes all broadcasts sequentially within one job to guarantee parent-before-child ordering, preventing silent drops when child broadcasts arrive before parent DOM exists.



152
153
154
155
156
157
158
159
160
161
# File 'app/models/collavre/creative/realtime_broadcastable.rb', line 152

def self.broadcast_batch_created(creatives)
  return if creatives.blank?

  creative_ids = creatives.map(&:id)
  CreativeBroadcastJob.perform_later(
    creative_ids,
    "batch_created",
    current_user_id: broadcast_excludable_user_id
  )
end

.broadcast_excludable_user_idObject

Returns nil for MCP requests so broadcast includes all users; returns current_user’s ID for web requests so the requester is excluded.



259
260
261
# File 'app/models/collavre/creative/realtime_broadcastable.rb', line 259

def self.broadcast_excludable_user_id
  Collavre::Current.mcp_request ? nil : Collavre.current_user&.id
end

Instance Method Details

#add_progress_text!(data, target_user) ⇒ Object

Add per-user progress_text to payload and ancestors



193
194
195
196
197
198
199
200
201
202
203
# File 'app/models/collavre/creative/realtime_broadcastable.rb', line 193

def add_progress_text!(data, target_user)
  # Main creative progress text
  if data.dig(:inline_editor_payload, :progress)
    data[:progress_text] = format_progress_text(data[:inline_editor_payload][:progress], target_user)
  end

  # Ancestor progress text
  data[:ancestors]&.each do |anc|
    anc[:progress_text] = format_progress_text(anc[:progress], target_user) if anc[:progress]
  end
end

#broadcast_creative_created(after_id: nil) ⇒ Object

Public: called from CreateService after insert_at_position

Parameters:

  • after_id (Integer, nil) (defaults to: nil)

    the creative ID this was inserted after (from user action)



17
18
19
20
21
22
23
24
# File 'app/models/collavre/creative/realtime_broadcastable.rb', line 17

def broadcast_creative_created(after_id: nil)
  payload = broadcast_node_payload
  payload[:after_id] = after_id.presence&.to_i
  payload[:previous_sibling_id] = previous_sibling&.id
  enqueue_broadcast(:created, payload)
rescue StandardError => e
  Rails.logger.error "[CreativeBroadcast] ERROR in broadcast_creative_created: #{e.message}\n#{e.backtrace.first(5).join("\n")}"
end

#broadcast_creative_destroyedObject



60
61
62
63
64
65
66
67
68
69
70
71
72
73
# File 'app/models/collavre/creative/realtime_broadcastable.rb', line 60

def broadcast_creative_destroyed
  return if @_destroy_broadcast_users.blank?

  CreativeBroadcastJob.perform_later(
    id,
    "destroyed",
    current_user_id: broadcast_excludable_user_id,
    payload: @_destroy_payload,
    options: {
      destroy_user_ids: @_destroy_broadcast_users.map(&:id),
      destroy_linked_map: @_destroy_linked_map.transform_keys(&:to_s)
    }
  )
end

#broadcast_creative_updatedObject



26
27
28
29
30
31
32
33
34
35
36
37
# File 'app/models/collavre/creative/realtime_broadcastable.rb', line 26

def broadcast_creative_updated
  # Skip broadcast when only progress changed (cascade from update_parent_progress).
  # The original creative's broadcast already includes ancestor progress in its payload,
  # so receivers update parent rows without needing separate broadcasts per ancestor.
  # Exception: MCP requests must always broadcast because the browser has no HTTP
  # response to update from — WebSocket is the only delivery channel.
  return if progress_only_change? && !Collavre::Current.mcp_request

  enqueue_broadcast(:updated, broadcast_node_payload)
rescue StandardError => e
  Rails.logger.error "[CreativeBroadcast] ERROR in broadcast_creative_updated: #{e.message}\n#{e.backtrace.first(5).join("\n")}"
end

#broadcast_excludable_user_idObject



263
264
265
# File 'app/models/collavre/creative/realtime_broadcastable.rb', line 263

def broadcast_excludable_user_id
  RealtimeBroadcastable.broadcast_excludable_user_id
end

#broadcast_node_payloadObject

Tree-renderer compatible node payload (matches TreeBuilder output)



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
# File 'app/models/collavre/creative/realtime_broadcastable.rb', line 76

def broadcast_node_payload
  origin = safe_effective_origin
  desc_html = origin.effective_description
  desc_raw = description

  # Reload to get latest progress values (after update_parent_progress in after_save)
  fresh_ancestors = reload.ancestors

  {
    # Core node properties (tree_renderer.applyRowProperties)
    id: id,
    dom_id: "creative-#{id}",
    parent_id: parent_id,
    level: fresh_ancestors.size + 1,
    select_mode: false,
    can_write: true, # Default; overridden per-user in Job
    has_children: children.exists?,
    expanded: false,
    is_root: parent.nil?,
    archived: archived?,
    link_url: "/creatives?id=#{id}",
    origin_id: origin_id,
    sequence: sequence,
    # Templates (for display)
    templates: {
      description_html: desc_html
      # progress_html intentionally omitted — minimal render would overwrite
      # the full server-rendered progress area (which includes chat badges etc.)
    },
    # Inline editor payload (for editor cache)
    inline_editor_payload: {
      description_raw_html: desc_raw,
      progress: progress,
      origin_id: origin_id
    },
    # Ancestors progress (for parent row updates)
    ancestors: fresh_ancestors.map { |a| { id: a.id, progress: a.progress } }
  }
end

#build_ancestor_ids_from_parent(creative) ⇒ Object

Fallback: walk parent_id chain when closure_tree hierarchy is unavailable (e.g. during before_destroy when hierarchy rows are already deleted)



207
208
209
210
211
212
213
214
215
216
# File 'app/models/collavre/creative/realtime_broadcastable.rb', line 207

def build_ancestor_ids_from_parent(creative)
  ids = []
  current = creative
  while current.parent_id.present?
    ids << current.parent_id
    current = Creative.find_by(id: current.parent_id)
    break unless current
  end
  ids
end

#build_linked_creative_map(users) ⇒ Object

Build a map of user_id → linked_creative_id for this creative So each user receives the correct ID for their view



165
166
167
168
169
170
171
172
173
174
175
176
177
# File 'app/models/collavre/creative/realtime_broadcastable.rb', line 165

def build_linked_creative_map(users)
  origin = safe_effective_origin
  map = {}
  # If this IS the origin, find linked creatives for each user
  origin.linked_creatives.where(user: users).find_each do |linked|
    map[linked.user_id] = linked.id
  end
  # Also map ancestors' linked creatives
  map
rescue StandardError => e
  Rails.logger.error "[CreativeBroadcast] Error building linked map: #{e.message}"
  {}
end

#capture_broadcast_stateObject



39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
# File 'app/models/collavre/creative/realtime_broadcastable.rb', line 39

def capture_broadcast_state
  # Skip for top-level personal creatives with no links
  # (parent_id.nil? means no ancestors to inherit shares from)
  return if parent_id.nil? && !origin_id && linked_creatives.none?

  # Capture before destroy — after destroy, associations are gone
  @_destroy_broadcast_users = find_broadcast_users
  return if @_destroy_broadcast_users.empty?

  @_destroy_linked_map = build_linked_creative_map(@_destroy_broadcast_users)

  # ancestors may be empty if closure_tree already deleted hierarchy rows,
  # so fall back to parent_id chain
  ancestor_list = ancestors.presence || Creative.where(id: build_ancestor_ids_from_parent(self))
  @_destroy_payload = {
    id: id,
    parent_id: parent_id,
    ancestors: ancestor_list.map { |a| { id: a.id, progress: a.progress } }
  }
end

#enqueue_broadcast(action, payload) ⇒ Object

Enqueue broadcast as a background job to avoid blocking the request cycle. Payload is built synchronously (needs fresh DB state), then delivery is async. For MCP requests, current_user_id is nil so the job broadcasts to ALL users including the requester (whose browser relies solely on WebSocket updates).



140
141
142
143
144
145
146
147
# File 'app/models/collavre/creative/realtime_broadcastable.rb', line 140

def enqueue_broadcast(action, payload)
  CreativeBroadcastJob.perform_later(
    id,
    action.to_s,
    current_user_id: broadcast_excludable_user_id,
    payload: payload
  )
end

#find_broadcast_usersObject



218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
# File 'app/models/collavre/creative/realtime_broadcastable.rb', line 218

def find_broadcast_users
  target = begin
    origin_id.present? && origin ? origin.effective_origin : effective_origin
  rescue ActiveRecord::RecordNotFound
    self
  end

  # Batch: collect all ancestor IDs + target in one query, then load shares in one query
  # NOTE: ancestor_ids uses closure_tree's hierarchy table, but during before_destroy
  # the hierarchy rows may already be deleted by closure_tree's own before_destroy callback.
  # Fallback to parent_id chain when ancestor_ids returns empty for a creative with parent.
  ancestor_ids = target.ancestor_ids  # closure_tree: single CTE query
  if ancestor_ids.empty? && target.parent_id.present?
    ancestor_ids = build_ancestor_ids_from_parent(target)
  end
  all_creative_ids = [ target.id ] + ancestor_ids

  # Owner users — single query
  owners = User.where(id: Creative.where(id: all_creative_ids).select(:user_id))

  # Shared users — single query (instead of N+1 per ancestor)
  shared_users = User.where(
    id: CreativeShare.where(creative_id: all_creative_ids).select(:user_id)
  )

  candidates = (owners + shared_users).compact.uniq

  # Filter: only users who actually have read permission.
  # Check target first; if its permission cache is missing (e.g. newly created creative
  # whose PermissionCacheJob hasn't run yet), fall back to the nearest ancestor that has
  # a cache entry. This prevents broadcast from being silently dropped for new creatives.
  permission_targets = [ target ] + Creative.where(id: ancestor_ids).order(:id).reverse
  candidates.select do |user|
    permission_targets.any? { |pt| pt.has_permission?(user, :read) }
  end
rescue StandardError => e
  Rails.logger.error "[CreativeBroadcast] Error finding users: #{e.message}"
  []
end

#format_progress_text(progress_value, _user = nil) ⇒ Object

Format progress text for a user (100% → completion mark if set) Matches render_progress_value helper: when value==1 and completion_mark is not nil, use it (even if blank — blank becomes non-breaking spaces in display)



182
183
184
185
186
187
188
189
190
# File 'app/models/collavre/creative/realtime_broadcastable.rb', line 182

def format_progress_text(progress_value, _user = nil)
  pct = ((progress_value || 0) * 100).round
  completion_mark = Collavre::SystemSetting.completion_mark
  if pct >= 100 && !completion_mark.nil?
    completion_mark
  else
    "#{pct}%"
  end
end

#previous_siblingObject



131
132
133
134
# File 'app/models/collavre/creative/realtime_broadcastable.rb', line 131

def previous_sibling
  scope = parent ? parent.children : Creative.where(parent_id: nil)
  scope.where("sequence < ?", sequence).order(sequence: :desc).first
end

#progress_only_change?Boolean

Returns true when update_parent_progress cascaded a progress-only save. In that case, the child’s broadcast already carries ancestor progress data, so a separate broadcast for the parent is redundant.

Returns:

  • (Boolean)


119
120
121
122
123
# File 'app/models/collavre/creative/realtime_broadcastable.rb', line 119

def progress_only_change?
  return false unless previous_changes.key?("progress")

  (previous_changes.keys - %w[progress updated_at]).empty?
end

#safe_effective_originObject



125
126
127
128
129
# File 'app/models/collavre/creative/realtime_broadcastable.rb', line 125

def safe_effective_origin
  effective_origin
rescue StandardError
  self
end