Class: ContentLink
- Inherits:
-
ApplicationRecord
- Object
- ActiveRecord::Base
- ApplicationRecord
- ContentLink
- Defined in:
- app/models/content_link.rb
Overview
== Schema Information
Table name: content_links
Database name: primary
id :bigint not null, primary key
context :text
created_by_type :string default("manual"), not null
link_type :string not null
position :integer
source_key :text generated, stored
target_key :text generated, stored
created_at :datetime not null
updated_at :datetime not null
source_article_id :bigint
source_digital_asset_id :bigint
source_item_id :bigint
source_showcase_id :bigint
target_article_id :bigint
target_digital_asset_id :bigint
target_item_id :bigint
target_showcase_id :bigint
target_support_case_id :bigint
Indexes
idx_content_links_unique_pair (source_key,target_key,link_type) UNIQUE
idx_content_links_source_article (source_article_id) WHERE (source_article_id IS NOT NULL)
idx_content_links_source_digital_asset (source_digital_asset_id) WHERE (source_digital_asset_id IS NOT NULL)
idx_content_links_source_item (source_item_id) WHERE (source_item_id IS NOT NULL)
idx_content_links_source_showcase (source_showcase_id) WHERE (source_showcase_id IS NOT NULL)
idx_content_links_target_article (target_article_id) WHERE (target_article_id IS NOT NULL)
idx_content_links_target_digital_asset (target_digital_asset_id) WHERE (target_digital_asset_id IS NOT NULL)
idx_content_links_target_item (target_item_id) WHERE (target_item_id IS NOT NULL)
idx_content_links_target_showcase (target_showcase_id) WHERE (target_showcase_id IS NOT NULL)
idx_content_links_target_support_case (target_support_case_id) WHERE (target_support_case_id IS NOT NULL)
Foreign Keys
fk_content_links_source_article (source_article_id => articles.id) ON DELETE => cascade
fk_content_links_source_digital_asset (source_digital_asset_id => digital_assets.id) ON DELETE => cascade
fk_content_links_source_item (source_item_id => items.id) ON DELETE => cascade
fk_content_links_source_showcase (source_showcase_id => showcases.id) ON DELETE => cascade
fk_content_links_target_article (target_article_id => articles.id) ON DELETE => cascade
fk_content_links_target_digital_asset (target_digital_asset_id => digital_assets.id) ON DELETE => cascade
fk_content_links_target_item (target_item_id => items.id) ON DELETE => cascade
fk_content_links_target_showcase (target_showcase_id => showcases.id) ON DELETE => cascade
fk_content_links_target_support_case (target_support_case_id => support_cases.id) ON DELETE => cascade
A curated editorial cross-link between two content records, managed from the
CRM editor (or by Sunny via approved tools). Related public content may render
on a public page; case_evidence is internal provenance and must not.
Storage is the exclusive-arc pattern: one nullable, DB-enforced FK column
per linkable type and arm (source_/target_), with a CHECK constraint
guaranteeing exactly one owner per arm and ON DELETE CASCADE guaranteeing
links die with either endpoint. The polymorphic-looking API (source,
target, source_type=, target_id=, …) is preserved via shims so
callers, forms, and Sunny tools are unchanged. See
doc/tasks/202607041714_POLYMORPHIC_FK_INTEGRITY_AUDIT.md (Pattern B).
Constant Summary collapse
- LINK_TYPES =
Recognised link types.
%w[ related_article related_post related_video related_showcase related_faq related_publication case_evidence see_also ].freeze
- CREATED_BY_TYPES =
Recognised created by types.
%w[manual seo_recommendation sunny].freeze
- TECHNICAL_ARTICLE_WORKFLOW_LINK_TYPE =
Technical article workflow link type.
'related_article'- TARGET_TYPE_CONFIG =
Registry of supported target types — drives the CRM editor dropdowns and lookup URLs.
Keys are base-class names for STI models (Post -> 'Article', Video -> 'DigitalAsset').
Add entries here as new content types become linkable — and give the new
type its arc columns via migration (see ConvertContentLinksToExclusiveArc). { 'Article' => { label: 'Blog Post', target_type: 'Article', link_type: 'related_post', display_field: :subject, search_class: -> { Post }, published: true }, 'DigitalAsset' => { label: 'Video', target_type: 'DigitalAsset', link_type: 'related_video', display_field: :title, search_class: -> { Video }, published: false }, 'Showcase' => { label: 'Showcase', target_type: 'Showcase', link_type: 'related_showcase', display_field: :name, search_class: -> { Showcase }, published: true }, 'Item' => { label: 'Publication', target_type: 'Item', link_type: 'related_publication', display_field: :name, search_class: -> { Item }, base_scope: :publications, published: false }, 'SupportCase' => { label: 'Case Evidence', target_type: 'SupportCase', link_type: 'case_evidence', display_field: :selection_name, search_class: -> { SupportCase }, search_fields: [:description], prefix_search_fields: [:case_number], where: { case_type: 'Tech' }, order: { case_number: :desc }, published: false } }.freeze
- TECHNICAL_ARTICLE_TARGET_TYPE_CONFIG =
Technical article target type config.
{ 'ArticleTechnical' => { label: 'Technical Article', target_type: 'Article', link_type: 'related_article', display_field: :subject, search_class: -> { ArticleTechnical }, states: %w[published internal] }, 'ArticleFaq' => { label: 'FAQ', target_type: 'Article', link_type: 'related_faq', display_field: :subject, search_class: -> { ArticleFaq }, states: %w[published] }, 'Post' => TARGET_TYPE_CONFIG.fetch('Article'), 'DigitalAsset' => TARGET_TYPE_CONFIG.fetch('DigitalAsset'), 'Showcase' => TARGET_TYPE_CONFIG.fetch('Showcase'), 'Item' => TARGET_TYPE_CONFIG.fetch('Item'), 'SupportCase' => TARGET_TYPE_CONFIG.fetch('SupportCase') }.freeze
- DEFAULT_EDITOR_TARGET_TYPES =
Default editor target types.
%w[Article DigitalAsset Showcase Item].freeze
- SOURCE_ARCS =
base-class name => arc association, per arm. The single source of truth
for the exclusive-arc mapping; CrossLinkable derives its foreign keys
from these. { 'Article' => :source_article, 'DigitalAsset' => :source_digital_asset, 'Showcase' => :source_showcase, 'Item' => :source_item }.freeze
- TARGET_ARCS =
Target arcs.
{ 'Article' => :target_article, 'DigitalAsset' => :target_digital_asset, 'Showcase' => :target_showcase, 'Item' => :target_item, 'SupportCase' => :target_support_case }.freeze
- LINKABLE_TYPES =
Backward-compatible union for callers that need every supported endpoint.
(SOURCE_ARCS.keys | TARGET_ARCS.keys).freeze
Constants included from Models::Schedulable
Models::Schedulable::SIMPLE_FORM_OPTIONS
Instance Attribute Summary collapse
-
#created_by_type ⇒ Object
readonly
Validates created by type.
-
#link_type ⇒ Object
readonly
Validates link type.
Belongs to collapse
-
#source_article ⇒ Article?
The source article this record belongs to.
-
#source_digital_asset ⇒ DigitalAsset?
The source digital asset this record belongs to.
-
#source_item ⇒ Item?
The source item this record belongs to.
-
#source_showcase ⇒ Showcase?
The source showcase this record belongs to.
-
#target_article ⇒ Article?
The target article this record belongs to.
-
#target_digital_asset ⇒ DigitalAsset?
The target digital asset this record belongs to.
-
#target_item ⇒ Item?
The target item this record belongs to.
-
#target_showcase ⇒ Showcase?
The target showcase this record belongs to.
-
#target_support_case ⇒ SupportCase?
The target support case this record belongs to.
Class Method Summary collapse
-
.display_name_for(target) ⇒ String
Resolves a link target's configured human-readable label.
-
.editor_target_config(source, target_type:, link_type:) ⇒ Hash?
Resolves the allowlisted configuration for a persisted target/link pair.
-
.editor_target_type_config(source) ⇒ Object
Editor target type config.
-
.eligible_target_scope(config) ⇒ ActiveRecord::Relation
Builds the allowlisted relation used by every related-material picker and write path.
-
.excluding_workflow_managed ⇒ ActiveRecord::Relation<ContentLink>
A relation of ContentLinks that are excluding workflow managed.
-
.from_source ⇒ ActiveRecord::Relation<ContentLink>
A relation of ContentLinks that are from source.
-
.of_type ⇒ ActiveRecord::Relation<ContentLink>
A relation of ContentLinks that are of type.
-
.ordered ⇒ ActiveRecord::Relation<ContentLink>
A relation of ContentLinks that are ordered.
-
.reserved_workflow_context?(context) ⇒ Boolean
Returns whether a context value is reserved, independently of link type.
-
.targeting ⇒ ActiveRecord::Relation<ContentLink>
A relation of ContentLinks that are targeting.
-
.targets_for(entries) ⇒ Hash
Batch-loads the targets referenced by serialized ContentLink rows.
-
.with_sources ⇒ ActiveRecord::Relation<ContentLink>
A relation of ContentLinks that are with sources.
-
.with_targets ⇒ ActiveRecord::Relation<ContentLink>
A relation of ContentLinks that are with targets.
-
.workflow_context?(link_type:, context:) ⇒ Boolean
Returns whether a context value is reserved for Technical Article consolidation state.
-
.workflow_contexts ⇒ Array<String>
Returns every context reserved for consolidation state.
Instance Method Summary collapse
-
#complete_technical_article_consolidation! ⇒ Boolean
Marks a pending consolidation source as durable provenance.
-
#created_by_badge_class ⇒ Object
Created by badge class.
-
#created_by_label ⇒ Object
Created by label.
-
#source ⇒ ApplicationRecord?
Whichever source arc is set.
-
#source=(record) ⇒ Object
Assigns the source record to its arc, clearing the others.
-
#source_id ⇒ Object
Source id.
-
#source_id=(id) ⇒ Object
Sets the source id.
-
#source_type ⇒ String?
Base-class name of the set arc — same value the old polymorphic source_type column held.
-
#source_type=(type) ⇒ Object
type/id writer shims — keep mass assignment from forms and Sunny tools working (
ContentLink.new(target_type: 'Article', target_id: 3)). -
#target ⇒ ApplicationRecord?
Whichever target arc is set.
-
#target=(record) ⇒ Object
Sets the target.
-
#target_display_name ⇒ Object
── Display helpers ─────────────────────────────────────────────────────────.
-
#target_id ⇒ Object
Target id.
-
#target_id=(id) ⇒ Object
Sets the target id.
-
#target_type ⇒ Object
Target type.
-
#target_type=(type) ⇒ Object
Sets the target type.
-
#workflow_managed? ⇒ Boolean
Returns whether this link is immutable Technical Article workflow state.
Methods inherited from ApplicationRecord
ransackable_associations, ransackable_attributes, ransackable_scopes, ransortable_attributes, #to_relation
Methods included from Models::Schedulable
Methods included from Models::AfterCommittable
Methods included from Models::EventPublishable
Instance Attribute Details
#created_by_type ⇒ Object (readonly)
Validates created by type.
Validations:
- Inclusion ({ in: CREATED_BY_TYPES })
156 |
# File 'app/models/content_link.rb', line 156 validates :created_by_type, inclusion: { in: CREATED_BY_TYPES } |
#link_type ⇒ Object (readonly)
Validates link type.
Validations:
154 |
# File 'app/models/content_link.rb', line 154 validates :link_type, presence: true, inclusion: { in: LINK_TYPES } |
Class Method Details
.display_name_for(target) ⇒ String
Resolves a link target's configured human-readable label.
271 272 273 274 275 276 |
# File 'app/models/content_link.rb', line 271 def self.display_name_for(target) return 'Unknown' unless target field = TARGET_TYPE_CONFIG.dig(target.class.base_class.name, :display_field) field ? target.public_send(field) : target.to_s end |
.editor_target_config(source, target_type:, link_type:) ⇒ Hash?
Resolves the allowlisted configuration for a persisted target/link pair.
225 226 227 228 229 |
# File 'app/models/content_link.rb', line 225 def self.editor_target_config(source, target_type:, link_type:) editor_target_type_config(source).values.find do |config| config[:target_type] == target_type.to_s && config[:link_type] == link_type.to_s end end |
.editor_target_type_config(source) ⇒ Object
Editor target type config.
213 214 215 216 217 |
# File 'app/models/content_link.rb', line 213 def self.editor_target_type_config(source) return TECHNICAL_ARTICLE_TARGET_TYPE_CONFIG if source.is_a?(ArticleTechnical) TARGET_TYPE_CONFIG.slice(*DEFAULT_EDITOR_TARGET_TYPES) end |
.eligible_target_scope(config) ⇒ ActiveRecord::Relation
Builds the allowlisted relation used by every related-material picker and
write path. Keeping eligibility here prevents a forged ID from selecting a
record that the corresponding search UI would never offer.
237 238 239 240 241 242 243 244 245 246 247 |
# File 'app/models/content_link.rb', line 237 def self.eligible_target_scope(config) klass = config.fetch(:search_class).call scope = klass.all scope = scope.public_send(config[:base_scope]) if config[:base_scope] scope = scope.where(state: config[:states]) if config[:states] scope = scope.published if config[:published] uses_active_scope = config[:base_scope].nil? || config[:base_scope] == :publications scope = scope.active if uses_active_scope && scope.respond_to?(:active) && klass.column_names.include?('is_discontinued') scope = scope.where(inactive: false) if klass.column_names.include?('inactive') config[:where] ? scope.where(config[:where]) : scope end |
.excluding_workflow_managed ⇒ ActiveRecord::Relation<ContentLink>
A relation of ContentLinks that are excluding workflow managed. Active Record Scope
167 168 169 170 171 172 173 174 175 |
# File 'app/models/content_link.rb', line 167 scope :excluding_workflow_managed, lambda { workflow_ids = ContentLink .where( link_type: TECHNICAL_ARTICLE_WORKFLOW_LINK_TYPE, context: ContentLink.workflow_contexts ) .select(:id) where.not(id: workflow_ids) } |
.from_source ⇒ ActiveRecord::Relation<ContentLink>
A relation of ContentLinks that are from source. Active Record Scope
177 |
# File 'app/models/content_link.rb', line 177 scope :from_source, ->(record) { where(SOURCE_ARCS.fetch(record.class.base_class.name) => record) } |
.of_type ⇒ ActiveRecord::Relation<ContentLink>
A relation of ContentLinks that are of type. Active Record Scope
165 |
# File 'app/models/content_link.rb', line 165 scope :of_type, ->(type) { where(link_type: type) } |
.ordered ⇒ ActiveRecord::Relation<ContentLink>
A relation of ContentLinks that are ordered. Active Record Scope
166 |
# File 'app/models/content_link.rb', line 166 scope :ordered, -> { order(Arel.sql('position ASC NULLS LAST, created_at ASC')) } |
.reserved_workflow_context?(context) ⇒ Boolean
Returns whether a context value is reserved, independently of link type.
197 198 199 |
# File 'app/models/content_link.rb', line 197 def self.reserved_workflow_context?(context) context.to_s.in?(workflow_contexts) end |
.targeting ⇒ ActiveRecord::Relation<ContentLink>
A relation of ContentLinks that are targeting. Active Record Scope
176 |
# File 'app/models/content_link.rb', line 176 scope :targeting, ->(klass) { where.not(TARGET_ARCS.fetch(klass.base_class.name) => nil) } |
.targets_for(entries) ⇒ Hash
Batch-loads the targets referenced by serialized ContentLink rows.
254 255 256 257 258 259 260 261 262 263 264 265 |
# File 'app/models/content_link.rb', line 254 def self.targets_for(entries) Array(entries) .group_by { |entry| entry.to_h.stringify_keys['target_type'].to_s } .each_with_object({}) do |(target_type, rows), targets| next unless TARGET_ARCS.key?(target_type) target_ids = rows.map { |row| row.to_h.stringify_keys['target_id'].to_i }.select(&:positive?).uniq ApplicationRecord.polymorphic_class_for(target_type).where(id: target_ids).find_each do |target| targets[[target_type, target.id]] = target end end end |
.with_sources ⇒ ActiveRecord::Relation<ContentLink>
A relation of ContentLinks that are with sources. Active Record Scope
181 |
# File 'app/models/content_link.rb', line 181 scope :with_sources, -> { includes(*SOURCE_ARCS.values) } |
.with_targets ⇒ ActiveRecord::Relation<ContentLink>
A relation of ContentLinks that are with targets. Active Record Scope
180 |
# File 'app/models/content_link.rb', line 180 scope :with_targets, -> { includes(*TARGET_ARCS.values) } |
.workflow_context?(link_type:, context:) ⇒ Boolean
Returns whether a context value is reserved for Technical Article
consolidation state.
189 190 191 |
# File 'app/models/content_link.rb', line 189 def self.workflow_context?(link_type:, context:) link_type == TECHNICAL_ARTICLE_WORKFLOW_LINK_TYPE && reserved_workflow_context?(context) end |
.workflow_contexts ⇒ Array<String>
Returns every context reserved for consolidation state.
204 205 206 207 208 209 |
# File 'app/models/content_link.rb', line 204 def self.workflow_contexts [ ArticleTechnical::CONSOLIDATION_SOURCE_CONTEXT, ArticleTechnical::CONSOLIDATED_SOURCE_CONTEXT ] end |
Instance Method Details
#complete_technical_article_consolidation! ⇒ Boolean
Marks a pending consolidation source as durable provenance. This is the
sole permitted context transition for a workflow-managed link.
396 397 398 399 400 401 402 403 404 405 406 |
# File 'app/models/content_link.rb', line 396 def complete_technical_article_consolidation! unless workflow_managed? && context == ArticleTechnical::CONSOLIDATION_SOURCE_CONTEXT errors.add(:context, 'is not a pending Technical Article consolidation source') raise ActiveRecord::RecordInvalid, self end @allow_workflow_context_transition = true update!(context: ArticleTechnical::CONSOLIDATED_SOURCE_CONTEXT) ensure @allow_workflow_context_transition = false end |
#created_by_badge_class ⇒ Object
Created by badge class.
376 377 378 379 380 381 382 |
# File 'app/models/content_link.rb', line 376 def created_by_badge_class case created_by_type when 'seo_recommendation' then 'bg-info' when 'sunny' then 'bg-warning text-dark' else 'bg-secondary' end end |
#created_by_label ⇒ Object
Created by label.
367 368 369 370 371 372 373 |
# File 'app/models/content_link.rb', line 367 def created_by_label case created_by_type when 'seo_recommendation' then 'SEO' when 'sunny' then 'Sunny' else 'Manual' end end |
#source ⇒ ApplicationRecord?
Returns whichever source arc is set.
281 282 283 |
# File 'app/models/content_link.rb', line 281 def source source_article || source_digital_asset || source_showcase || source_item end |
#source=(record) ⇒ Object
Assigns the source record to its arc, clearing the others. STI records
collapse to their base class (Post -> source_article, Video ->
source_digital_asset), matching what the polymorphic column stored.
294 295 296 |
# File 'app/models/content_link.rb', line 294 def source=(record) assign_arc(SOURCE_ARCS, record) end |
#source_article ⇒ Article?
Returns the source article this record belongs to.
135 |
# File 'app/models/content_link.rb', line 135 belongs_to :source_article, class_name: 'Article', optional: true |
#source_digital_asset ⇒ DigitalAsset?
Returns the source digital asset this record belongs to.
137 |
# File 'app/models/content_link.rb', line 137 belongs_to :source_digital_asset, class_name: 'DigitalAsset', optional: true |
#source_id ⇒ Object
Source id.
317 318 319 |
# File 'app/models/content_link.rb', line 317 def source_id SOURCE_ARCS.each_value.filter_map { |arc| public_send(:"#{arc}_id") }.first end |
#source_id=(id) ⇒ Object
Sets the source id.
340 341 342 343 |
# File 'app/models/content_link.rb', line 340 def source_id=(id) @pending_source_id = id.presence resolve_pending(SOURCE_ARCS, @pending_source_type, @pending_source_id) end |
#source_item ⇒ Item?
Returns the source item this record belongs to.
141 |
# File 'app/models/content_link.rb', line 141 belongs_to :source_item, class_name: 'Item', optional: true |
#source_showcase ⇒ Showcase?
Returns the source showcase this record belongs to.
139 |
# File 'app/models/content_link.rb', line 139 belongs_to :source_showcase, class_name: 'Showcase', optional: true |
#source_type ⇒ String?
Returns base-class name of the set arc — same value the old
polymorphic source_type column held.
307 308 309 |
# File 'app/models/content_link.rb', line 307 def source_type SOURCE_ARCS.each_key.find { |type| arc_set?(SOURCE_ARCS[type]) } end |
#source_type=(type) ⇒ Object
type/id writer shims — keep mass assignment from forms and Sunny tools
working (ContentLink.new(target_type: 'Article', target_id: 3)).
Order-independent: the arc is resolved once both halves are present.
Unknown types set nothing, so exactly_one_target rejects the record
instead of constantize raising (or worse, binding to the wrong table).
332 333 334 335 |
# File 'app/models/content_link.rb', line 332 def source_type=(type) @pending_source_type = type.presence resolve_pending(SOURCE_ARCS, @pending_source_type, @pending_source_id) end |
#target ⇒ ApplicationRecord?
Returns whichever target arc is set.
286 287 288 |
# File 'app/models/content_link.rb', line 286 def target target_article || target_digital_asset || target_showcase || target_item || target_support_case end |
#target=(record) ⇒ Object
Sets the target.
301 302 303 |
# File 'app/models/content_link.rb', line 301 def target=(record) assign_arc(TARGET_ARCS, record) end |
#target_article ⇒ Article?
Returns the target article this record belongs to.
143 |
# File 'app/models/content_link.rb', line 143 belongs_to :target_article, class_name: 'Article', optional: true |
#target_digital_asset ⇒ DigitalAsset?
Returns the target digital asset this record belongs to.
145 |
# File 'app/models/content_link.rb', line 145 belongs_to :target_digital_asset, class_name: 'DigitalAsset', optional: true |
#target_display_name ⇒ Object
── Display helpers ─────────────────────────────────────────────────────────
362 363 364 |
# File 'app/models/content_link.rb', line 362 def target_display_name self.class.display_name_for(target) end |
#target_id ⇒ Object
Target id.
322 323 324 |
# File 'app/models/content_link.rb', line 322 def target_id TARGET_ARCS.each_value.filter_map { |arc| public_send(:"#{arc}_id") }.first end |
#target_id=(id) ⇒ Object
Sets the target id.
356 357 358 359 |
# File 'app/models/content_link.rb', line 356 def target_id=(id) @pending_target_id = id.presence resolve_pending(TARGET_ARCS, @pending_target_type, @pending_target_id) end |
#target_item ⇒ Item?
Returns the target item this record belongs to.
149 |
# File 'app/models/content_link.rb', line 149 belongs_to :target_item, class_name: 'Item', optional: true |
#target_showcase ⇒ Showcase?
Returns the target showcase this record belongs to.
147 |
# File 'app/models/content_link.rb', line 147 belongs_to :target_showcase, class_name: 'Showcase', optional: true |
#target_support_case ⇒ SupportCase?
Returns the target support case this record belongs to.
151 |
# File 'app/models/content_link.rb', line 151 belongs_to :target_support_case, class_name: 'SupportCase', optional: true |
#target_type ⇒ Object
Target type.
312 313 314 |
# File 'app/models/content_link.rb', line 312 def target_type TARGET_ARCS.each_key.find { |type| arc_set?(TARGET_ARCS[type]) } end |
#target_type=(type) ⇒ Object
Sets the target type.
348 349 350 351 |
# File 'app/models/content_link.rb', line 348 def target_type=(type) @pending_target_type = type.presence resolve_pending(TARGET_ARCS, @pending_target_type, @pending_target_id) end |
#workflow_managed? ⇒ Boolean
Returns whether this link is immutable Technical Article workflow state.
387 388 389 |
# File 'app/models/content_link.rb', line 387 def workflow_managed? self.class.workflow_context?(link_type:, context:) end |