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
-
.broadcast_batch_created(creatives) ⇒ Object
Enqueue a single batch job for multiple created creatives (e.g. MarkdownImporter).
-
.broadcast_excludable_user_id ⇒ Object
Returns nil for MCP requests so broadcast includes all users; returns current_user’s ID for web requests so the requester is excluded.
Instance Method Summary collapse
-
#add_progress_text!(data, target_user) ⇒ Object
Add per-user progress_text to payload and ancestors.
-
#broadcast_creative_created(after_id: nil) ⇒ Object
Public: called from CreateService after insert_at_position.
- #broadcast_creative_destroyed ⇒ Object
- #broadcast_creative_updated ⇒ Object
- #broadcast_excludable_user_id ⇒ Object
-
#broadcast_node_payload ⇒ Object
Tree-renderer compatible node payload (matches TreeBuilder output).
-
#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).
-
#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.
- #capture_broadcast_state ⇒ Object
-
#enqueue_broadcast(action, payload) ⇒ Object
Enqueue broadcast as a background job to avoid blocking the request cycle.
- #find_broadcast_users ⇒ Object
-
#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).
- #previous_sibling ⇒ Object
-
#progress_only_change? ⇒ Boolean
Returns true when update_parent_progress cascaded a progress-only save.
- #safe_effective_origin ⇒ Object
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_id ⇒ Object
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
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.}\n#{e.backtrace.first(5).join("\n")}" end |
#broadcast_creative_destroyed ⇒ Object
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_updated ⇒ Object
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.}\n#{e.backtrace.first(5).join("\n")}" end |
#broadcast_excludable_user_id ⇒ Object
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_payload ⇒ Object
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.}" {} end |
#capture_broadcast_state ⇒ Object
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_users ⇒ Object
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. = [ target ] + Creative.where(id: ancestor_ids).order(:id).reverse candidates.select do |user| .any? { |pt| pt.(user, :read) } end rescue StandardError => e Rails.logger.error "[CreativeBroadcast] Error finding users: #{e.}" [] 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_sibling ⇒ Object
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.
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_origin ⇒ Object
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 |