Class: Collavre::Creative

Inherits:
ApplicationRecord show all
Includes:
Describable, Linkable, Permissible, RealtimeBroadcastable
Defined in:
app/models/collavre/creative.rb,
app/models/collavre/creative/linkable.rb,
app/models/collavre/creative/describable.rb,
app/models/collavre/creative/permissible.rb,
app/models/collavre/creative/realtime_broadcastable.rb

Defined Under Namespace

Modules: Describable, Linkable, Permissible, RealtimeBroadcastable

Constant Summary collapse

BUILTIN_RESERVED_METADATA_KEYS =

Reserved metadata key registry — engines register their own namespaces so update_metadata preserves them without core naming vendor-specific keys.

%w[markdown_source content_type editor].freeze
SYSTEM_TOPIC_NAME =
"System"
MAIN_TOPIC_NAME =
"Main"
CONTENT_TOPIC_NAME =
"Content"

Constants included from Permissible

Permissible::PERMISSION_INVALIDATING_ATTRIBUTES

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Methods included from RealtimeBroadcastable

#add_progress_text!, broadcast_batch_created, #broadcast_creative_created, #broadcast_creative_destroyed, #broadcast_creative_updated, broadcast_excludable_user_id, #broadcast_excludable_user_id, #broadcast_node_payload, #build_ancestor_ids_from_parent, #build_linked_creative_map, #capture_broadcast_state, #enqueue_broadcast, #find_broadcast_users, #format_progress_text, #previous_sibling, #progress_only_change?, #safe_effective_origin

Methods included from Describable

#attachment_node_html, #attachments_embeddable?, #creative_snippet, #effective_description, #embed_attachment_blob!, #remove_attachment!

Methods included from Permissible

#all_shared_users, #children_with_permission, #find_ai_agent, #has_permission?

Methods included from Linkable

#create_linked_creative_for_user, #effective_attribute, #effective_origin, #linked_children, #progress, #user

Instance Attribute Details

#filtered_progressObject

Returns the value of attribute filtered_progress.



138
139
140
# File 'app/models/collavre/creative.rb', line 138

def filtered_progress
  @filtered_progress
end

#skip_read_only_source_validationObject

Bypass the read-only-source guard for a single save (used by the vendor sync services that legitimately write the synced content into core).



82
83
84
# File 'app/models/collavre/creative.rb', line 82

def skip_read_only_source_validation
  @skip_read_only_source_validation
end

Class Method Details

.inbox_for(user) ⇒ Object

Find or create the inbox creative for a given user. Places it as a root creative (no parent) owned by the user.



126
127
128
129
130
131
132
133
134
135
136
# File 'app/models/collavre/creative.rb', line 126

def self.inbox_for(user)
  existing = where(user: user).inboxes.first
  return existing if existing

  create!(
    description: I18n.t("collavre.inbox.default_name"),
    data: { "kind" => "inbox" },
    user: user,
    progress: 0.0
  )
end

.read_only_source_typesObject


Read-only source registry — vendor engines register the data.source.type values whose content is owned by an external system (e.g. a synced GitHub repository) and therefore must not be edited in-app. Core enforces the read-only behavior via read_only_source? without naming any vendor.



33
34
35
# File 'app/models/collavre/creative.rb', line 33

def read_only_source_types
  @read_only_source_types ||= Set.new
end

.register_read_only_source(type) ⇒ Object



37
38
39
# File 'app/models/collavre/creative.rb', line 37

def register_read_only_source(type)
  read_only_source_types << type.to_s
end

.register_reserved_metadata_key(key) ⇒ Object



19
20
21
# File 'app/models/collavre/creative.rb', line 19

def (key)
   << key.to_s unless .include?(key.to_s)
end

.registered_reserved_metadata_keysObject



15
16
17
# File 'app/models/collavre/creative.rb', line 15

def 
  @registered_reserved_metadata_keys ||= []
end

.reserved_metadata_keysObject



23
24
25
# File 'app/models/collavre/creative.rb', line 23

def 
  (BUILTIN_RESERVED_METADATA_KEYS + ).freeze
end

.shared_accessible_ids(user) ⇒ Object

Returns IDs of creatives that have at least one active share and are accessible by the given user (owned or shared with/by them). Includes linked creatives whose origin has shares.



155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
# File 'app/models/collavre/creative.rb', line 155

def self.shared_accessible_ids(user)
  own_ids = where(user_id: user.id).pluck(:id)
  shared_ids = CreativeShare
    .where.not(permission: :no_access)
    .where("user_id = :uid OR shared_by_id = :uid", uid: user.id)
    .pluck(:creative_id)

  accessible_ids = (own_ids | shared_ids).uniq

  # Direct shares on accessible creatives
  directly_shared = CreativeShare
    .where(creative_id: accessible_ids)
    .where.not(permission: :no_access)
    .pluck(:creative_id)

  # Linked creatives whose origin has shares
  origin_shared = where(id: accessible_ids)
    .where.not(origin_id: nil)
    .joins("INNER JOIN creative_shares ON creative_shares.creative_id = creatives.origin_id")
    .where.not(creative_shares: { permission: :no_access })
    .pluck(:id)

  (directly_shared | origin_shared).uniq
end

Instance Method Details

#archive!Object



285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
# File 'app/models/collavre/creative.rb', line 285

def archive!
  now = Time.current
  self.class.transaction do
    # Archive self and descendants
    self_and_descendants.where(archived_at: nil).update_all(archived_at: now)

    # If this is a linked creative, also archive the origin and its descendants
    origin = effective_origin(Set.new)
    if origin != self
      origin.self_and_descendants.where(archived_at: nil).update_all(archived_at: now)
      # Archive all other linked creatives of the origin
      origin.linked_creatives.where.not(id: id).find_each do |linked|
        linked.self_and_descendants.where(archived_at: nil).update_all(archived_at: now)
      end
    end

    # Also archive any linked creatives that point to this one
    linked_creatives.find_each do |linked|
      linked.self_and_descendants.where(archived_at: nil).update_all(archived_at: now)
    end

    reload
    parent&.reload
    Collavre::Creatives::ProgressService.new(parent).update_progress_from_children! if parent
  end
end

#archived?Boolean

--- Archive ---

Returns:

  • (Boolean)


281
282
283
# File 'app/models/collavre/creative.rb', line 281

def archived?
  archived_at.present?
end

#childrenObject



246
247
248
249
# File 'app/models/collavre/creative.rb', line 246

def children
  # better not override this method, use children_with_permission instead or linked_children
  super
end

#content_topic(fallback_user: user) ⇒ Object



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

def content_topic(fallback_user: user)
  topics.find_or_create_by!(name: CONTENT_TOPIC_NAME) do |topic|
    topic.user = fallback_user
  end
end

#context_creativesObject

Returns context creatives (excludes self to avoid duplication).



234
235
236
237
238
# File 'app/models/collavre/creative.rb', line 234

def context_creatives
  ids = effective_context_ids
  ids -= [ id ]
  Creative.where(id: ids)
end

#context_idsObject

--- Context IDs --- Returns the directly-configured context creative IDs for this creative.



204
205
206
# File 'app/models/collavre/creative.rb', line 204

def context_ids
  Array(data&.dig("context_ids"))
end

#disabled_context_idsObject

Returns the directly-configured disabled context IDs for this creative.



209
210
211
# File 'app/models/collavre/creative.rb', line 209

def disabled_context_ids
  Array(data&.dig("disabled_context_ids"))
end

#drop_trigger_enabled?Boolean

--- Drop Trigger ---

Returns:

  • (Boolean)


198
199
200
# File 'app/models/collavre/creative.rb', line 198

def drop_trigger_enabled?
  data&.dig("trigger", "on_child_enter") == true
end

#effective_context_ids(visited_ids = Set.new) ⇒ Object

Returns the effective context IDs: own + inherited from ancestors (deduplicated).



214
215
216
217
218
219
220
221
# File 'app/models/collavre/creative.rb', line 214

def effective_context_ids(visited_ids = Set.new)
  return [] if visited_ids.include?(id)

  visited_ids.add(id)
  own = context_ids
  parent_ctx = parent&.effective_context_ids(visited_ids) || []
  (own + parent_ctx).uniq
end

#effective_disabled_context_ids(visited_ids = Set.new) ⇒ Object

Returns the effective disabled context IDs: own + inherited from ancestors (deduplicated).



224
225
226
227
228
229
230
231
# File 'app/models/collavre/creative.rb', line 224

def effective_disabled_context_ids(visited_ids = Set.new)
  return [] if visited_ids.include?(id)

  visited_ids.add(id)
  own = disabled_context_ids
  parent_disabled = parent&.effective_disabled_context_ids(visited_ids) || []
  (own + parent_disabled).uniq
end

#github_markdown?Boolean

GitHub-sourced content still needs a vendor-specific predicate for the comment view's inline-image rendering (a GitHub-only concern, distinct from the neutral read-only behavior above).

Returns:

  • (Boolean)


100
101
102
# File 'app/models/collavre/creative.rb', line 100

def github_markdown?
  source_type == "github_markdown"
end

#inbox?Boolean

Returns:

  • (Boolean)


76
77
78
# File 'app/models/collavre/creative.rb', line 76

def inbox?
  data&.dig("kind") == "inbox"
end

#main_topic(fallback_user: user) ⇒ Object



112
113
114
115
116
# File 'app/models/collavre/creative.rb', line 112

def main_topic(fallback_user: user)
  topics.find_or_create_by!(name: MAIN_TOPIC_NAME) do |topic|
    topic.user = fallback_user
  end
end

#progress_for_plan(tagged_ids) ⇒ Object



265
266
267
# File 'app/models/collavre/creative.rb', line 265

def progress_for_plan(tagged_ids)
  progress_service.progress_for_plan(tagged_ids)
end

#progress_for_tags(tag_ids, user = Collavre.current_user) ⇒ Object



261
262
263
# File 'app/models/collavre/creative.rb', line 261

def progress_for_tags(tag_ids, user = Collavre.current_user)
  progress_service.progress_for_tags(tag_ids, user)
end

#prompt_for(user) ⇒ Object



251
252
253
254
255
256
257
258
259
# File 'app/models/collavre/creative.rb', line 251

def prompt_for(user)
  comments
    .where(private: true, user: user)
    .where("content LIKE ?", "> %")
    .order(created_at: :desc)
    .first
    &.content
    &.sub(/\A>\s*/i, "")
end

#read_only_source?Boolean

Whether this creative's description is owned by an external, registered source and therefore read-only in-app.

Returns:

  • (Boolean)


92
93
94
95
# File 'app/models/collavre/creative.rb', line 92

def read_only_source?
  type = source_type
  type.present? && self.class.read_only_source_types.include?(type)
end

#source_typeObject

The registered source identifier for this creative, or nil when the description is authored in-app.



86
87
88
# File 'app/models/collavre/creative.rb', line 86

def source_type
  data.is_a?(Hash) ? data.dig("source", "type") : nil
end

#subtree_idsObject

Compatibility helper: ancestry gem exposes subtree_ids, while closure_tree typically uses self_and_descendants.



242
243
244
# File 'app/models/collavre/creative.rb', line 242

def subtree_ids
  self_and_descendants.pluck(:id)
end

#system_topic(fallback_user: user) ⇒ Object

Find or create the "System" topic for this inbox creative. Re-creates it if the user deletes it.



106
107
108
109
110
# File 'app/models/collavre/creative.rb', line 106

def system_topic(fallback_user: user)
  topics.find_or_create_by!(name: SYSTEM_TOPIC_NAME) do |topic|
    topic.user = fallback_user
  end
end

#to_partial_pathObject

Use non-namespaced partial path for backward compatibility



43
44
45
# File 'app/models/collavre/creative.rb', line 43

def to_partial_path
  "creatives/creative"
end

#unarchive!Object



312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
# File 'app/models/collavre/creative.rb', line 312

def unarchive!
  self.class.transaction do
    # Unarchive self and descendants
    self_and_descendants.where.not(archived_at: nil).update_all(archived_at: nil)

    # If this is a linked creative, also unarchive the origin and its descendants
    origin = effective_origin(Set.new)
    if origin != self
      origin.self_and_descendants.where.not(archived_at: nil).update_all(archived_at: nil)
      # Unarchive all other linked creatives of the origin
      origin.linked_creatives.where.not(id: id).find_each do |linked|
        linked.self_and_descendants.where.not(archived_at: nil).update_all(archived_at: nil)
      end
    end

    # Also unarchive any linked creatives that point to this one
    linked_creatives.find_each do |linked|
      linked.self_and_descendants.where.not(archived_at: nil).update_all(archived_at: nil)
    end

    reload
    parent&.reload
    Collavre::Creatives::ProgressService.new(parent).update_progress_from_children! if parent
  end
end

#update_parent_progressObject



269
270
271
272
273
274
275
276
277
278
# File 'app/models/collavre/creative.rb', line 269

def update_parent_progress
  progress_service.update_parent_progress!

  if saved_change_to_parent_id?
    old_parent_id = saved_change_to_parent_id[0]
    if old_parent_id && (old_parent = Creative.find_by(id: old_parent_id))
      Collavre::Creatives::ProgressService.new(old_parent).update_progress_from_children!
    end
  end
end