Class: ContentLink

Inherits:
ApplicationRecord show all
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

%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

Belongs to collapse

Class Method Summary collapse

Instance Method Summary collapse

Methods inherited from ApplicationRecord

ransackable_associations, ransackable_attributes, ransackable_scopes, ransortable_attributes, #to_relation

Methods included from Models::Schedulable

config

Methods included from Models::AfterCommittable

#after_commit

Methods included from Models::EventPublishable

#publish_event

Instance Attribute Details

#created_by_typeObject (readonly)

Validates created by type.

Validations:



156
# File 'app/models/content_link.rb', line 156

validates :created_by_type, inclusion: { in: CREATED_BY_TYPES }

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.

Parameters:

Returns:

  • (String)

    display label or Unknown when the target is unavailable



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.

Parameters:

  • source (ApplicationRecord)

    record owning the outbound link

  • target_type (String)

    exclusive-arc base class name

  • link_type (String)

    requested ContentLink relationship

Returns:

  • (Hash, nil)

    matching editor configuration



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.

Parameters:

  • source (Object)

    the source



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.

Parameters:

  • config (Hash)

    one entry from an editor target registry

Returns:

  • (ActiveRecord::Relation)

    eligible target records



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_managedActiveRecord::Relation<ContentLink>

A relation of ContentLinks that are excluding workflow managed. Active Record Scope

Returns:

See Also:



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_sourceActiveRecord::Relation<ContentLink>

A relation of ContentLinks that are from source. Active Record Scope

Returns:

See Also:



177
# File 'app/models/content_link.rb', line 177

scope :from_source, ->(record) { where(SOURCE_ARCS.fetch(record.class.base_class.name) => record) }

.of_typeActiveRecord::Relation<ContentLink>

A relation of ContentLinks that are of type. Active Record Scope

Returns:

See Also:



165
# File 'app/models/content_link.rb', line 165

scope :of_type, ->(type) { where(link_type: type) }

.orderedActiveRecord::Relation<ContentLink>

A relation of ContentLinks that are ordered. Active Record Scope

Returns:

See Also:



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.

Parameters:

  • context (String, nil)

    ContentLink context

Returns:

  • (Boolean)

    whether ordinary links may not use the context



197
198
199
# File 'app/models/content_link.rb', line 197

def self.reserved_workflow_context?(context)
  context.to_s.in?(workflow_contexts)
end

.targetingActiveRecord::Relation<ContentLink>

A relation of ContentLinks that are targeting. Active Record Scope

Returns:

See Also:



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.

Parameters:

  • entries (Array<Hash>)

    rows containing target_type and target_id

Returns:

  • (Hash)

    resolved targets keyed by [String target_type, Integer target_id];
    callers must coerce serialized target IDs before looking up a key



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_sourcesActiveRecord::Relation<ContentLink>

A relation of ContentLinks that are with sources. Active Record Scope

Returns:

See Also:



181
# File 'app/models/content_link.rb', line 181

scope :with_sources, -> { includes(*SOURCE_ARCS.values) }

.with_targetsActiveRecord::Relation<ContentLink>

A relation of ContentLinks that are with targets. Active Record Scope

Returns:

See Also:



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.

Parameters:

  • link_type (String, nil)

    ContentLink link type

  • context (String, nil)

    ContentLink context

Returns:

  • (Boolean)

    whether the context is workflow-managed



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_contextsArray<String>

Returns every context reserved for consolidation state.

Returns:

  • (Array<String>)

    pending and completed workflow contexts



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.

Returns:

  • (Boolean)

    whether the link was updated

Raises:

  • (ActiveRecord::RecordInvalid)

    if this is not a pending source marker



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_classObject

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_labelObject

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

#sourceApplicationRecord?

Returns whichever source arc is set.

Returns:



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.

Parameters:

  • record (Record)

    the record



294
295
296
# File 'app/models/content_link.rb', line 294

def source=(record)
  assign_arc(SOURCE_ARCS, record)
end

#source_articleArticle?

Returns the source article this record belongs to.

Returns:

  • (Article, nil)

    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_assetDigitalAsset?

Returns the source digital asset this record belongs to.

Returns:

  • (DigitalAsset, nil)

    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_idObject

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.

Parameters:

  • value (Object)

    the new source id

  • id (Integer)

    the id 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_itemItem?

Returns the source item this record belongs to.

Returns:

  • (Item, nil)

    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_showcaseShowcase?

Returns the source showcase this record belongs to.

Returns:

  • (Showcase, nil)

    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_typeString?

Returns base-class name of the set arc — same value the old
polymorphic source_type column held.

Returns:

  • (String, nil)

    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).

Parameters:

  • type (String)

    the type



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

#targetApplicationRecord?

Returns whichever target arc is set.

Returns:



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.

Parameters:

  • value (Object)

    the new target

  • record (Record)

    the record



301
302
303
# File 'app/models/content_link.rb', line 301

def target=(record)
  assign_arc(TARGET_ARCS, record)
end

#target_articleArticle?

Returns the target article this record belongs to.

Returns:

  • (Article, nil)

    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_assetDigitalAsset?

Returns the target digital asset this record belongs to.

Returns:

  • (DigitalAsset, nil)

    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_nameObject

── 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_idObject

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.

Parameters:

  • value (Object)

    the new target id

  • id (Integer)

    the id 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_itemItem?

Returns the target item this record belongs to.

Returns:

  • (Item, nil)

    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_showcaseShowcase?

Returns the target showcase this record belongs to.

Returns:

  • (Showcase, nil)

    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_caseSupportCase?

Returns the target support case this record belongs to.

Returns:

  • (SupportCase, nil)

    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_typeObject

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.

Parameters:

  • value (Object)

    the new target type

  • type (String)

    the 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.

Returns:

  • (Boolean)

    whether ordinary editors must leave the link unchanged



387
388
389
# File 'app/models/content_link.rb', line 387

def workflow_managed?
  self.class.workflow_context?(link_type:, context:)
end