Class: ArticleTechnical

Inherits:
Article show all
Defined in:
app/models/article_technical.rb

Overview

== Schema Information

Table name: articles
Database name: primary

id :integer not null, primary key
applies_to_product_category_ids :integer default([]), is an Array
applies_to_product_line_ids :integer default([]), is an Array
archived_at :datetime
auto_sanitize_html :boolean default(TRUE), not null
auto_sanitize_urls :boolean default(TRUE), not null
breadcrumbs :string default([]), not null, is an Array
department :string
description :text
has_toc :boolean default(FALSE), not null
inline_js :text
link_audit_result :jsonb
meta_description :string
meta_keywords :string
objective :text
problem_code :string(255)
publish_on :datetime
published_at :datetime
reading_time_minutes :integer
requires_seo_check :boolean default(FALSE), not null
revised_at :datetime
sales :boolean default(TRUE), not null
schema_extracted_at :datetime
schema_markup :jsonb
search_text :text
search_text_tsv :tsvector
seo_report :jsonb
serial_number_high_range :integer
serial_number_low_range :integer
short_url :string
short_url_with_ref :string
slug :string
slug_custom :string
solution :text
sort_booster :integer default(0), not null
state :string(20) default("draft")
subject :string(255) not null
support :boolean default(FALSE), not null
time_required :integer
title :string
toc_selector :string
type :string
version :integer
warranty_labor :boolean
warranty_parts :boolean
word_count :integer
created_at :datetime
updated_at :datetime
active_revision_id :bigint
brafton_id :integer
creator_id :integer
draft_revision_id :bigint
original_author_id :integer
precursor_id :integer
preview_image_id :string
related_article_1_id :integer
related_article_2_id :integer
related_article_3_id :integer
related_article_4_id :integer
source_id :integer
successor_article_id :integer
updater_id :integer
user_id :integer

Indexes

idx_articles_type_state_id (type,state,id)
idx_source_id_type (source_id,type)
idx_state_type_publish_on (state,type,publish_on)
index_articles_on_active_revision_id (active_revision_id)
index_articles_on_draft_revision_id (draft_revision_id)
index_articles_on_published_at (published_at) USING brin
index_articles_on_revised_at (revised_at)
index_articles_on_search_text_tsv (search_text_tsv) USING gin
index_articles_on_successor_article_id (successor_article_id)
index_articles_on_type_and_slug_unique (type,slug) UNIQUE

Foreign Keys

fk_rails_... (id => articles.id)
fk_rails_... (related_article_1_id => articles.id) ON DELETE => nullify
fk_rails_... (related_article_2_id => articles.id) ON DELETE => nullify
fk_rails_... (related_article_3_id => articles.id) ON DELETE => nullify
fk_rails_... (related_article_4_id => articles.id)
fk_rails_... (source_id => sources.id)
fk_rails_... (successor_article_id => articles.id)

Defined Under Namespace

Classes: Consolidator, PromoteAttachment

Constant Summary collapse

APPROVED_STATES =

States considered approved (can be replaced by a draft).

%w[published internal].freeze
ACTIVE_REPLACEMENT_STATES =

States in which a replacement draft is considered active.

%w[draft pending_review].freeze
CONSOLIDATION_SOURCE_CONTEXT =

Context string for consolidation source marker links.

'Technical Article consolidation source; archive when this draft is approved.'
CONSOLIDATED_SOURCE_CONTEXT =

Context string for consolidated source marker links.

'Consolidated source; superseded by this Technical Article.'
REVIEW_TRANSITION_EVENTS =

Mapping of review state transitions to the events that trigger them.

{
  [nil, 'draft'] => nil,
  %w[draft pending_review] => 'submit_for_review',
  %w[pending_review draft] => 'request_changes',
  %w[pending_review published] => 'publish',
  %w[pending_review internal] => 'approve_internal',
  %w[published archived] => 'archive',
  %w[internal archived] => 'archive',
  %w[archived draft] => 'redraft',
  %w[scheduled draft] => 'redraft'
}.freeze

Constants inherited from Article

Article::EMBEDDABLE_STATES, Article::FAQ_MAX_WORDS, Article::FAQ_TARGET_WORDS, Article::FAQ_WARN_WORDS, Article::FAQ_WORD_COUNT_SQL, Article::KNOWLEDGE_CORPUS_ROLES, Article::KNOWLEDGE_ROLES, Article::STI_TYPES, Article::UNREVIEWED_KNOWLEDGE_ROLE

Constants included from Models::Embeddable

Models::Embeddable::MAX_CONTENT_LENGTH

Constants included from Models::Auditable

Models::Auditable::ALWAYS_IGNORED

Constants included from Models::Schedulable

Models::Schedulable::SIMPLE_FORM_OPTIONS

Instance Attribute Summary

Attributes inherited from Article

#preserve_revision_slug, #schema_markup, #serial_number_high_range, #serial_number_low_range, #subject

Class Method Summary collapse

Instance Method Summary collapse

Methods inherited from Article

all_product_lines, #applicable_product_category_urls, #applicable_product_line_urls, archived, #article_pages, #author, available_schema_types, #breadcrumbs_hash, #build_revision_draft, #content_for_embedding, #create_revision_draft!, delete_content_embeddings_for, drafts, due_for_publishing, #effective_published_date, #effective_revised_date, #email_url, embeddable_content_types, #embedded_assets, embedded_in_posts, embedding_eligible, #embedding_eligible?, #embedding_type_name, #events_for_select, #faq_word_count, faqs, #file_name, for_item_sku, for_product_category_url_without_order, for_product_line_url, for_product_line_url_with_ancestors, for_product_line_url_with_descendants, for_product_line_url_without_order, for_product_line_urls_with_descendants, for_sales_portal, for_support_portal, #full_localized_solution, #generate_pdf, #get_img_src_urls, has_comments, has_schema, has_video, #item_product_lines, #items, knowledge_corpus, #knowledge_corpus?, #knowledge_role_human, knowledge_roles_for_select, #latest_revision, #link_checks, #liquid_variable_context, #localize_price, #localized_solution, #localized_solution_html_doc, most_recent_first, no_schema, #normalize_friendly_id, not_for_public, #ok_to_delete?, #open_revision, options_for_select, #original_author, orphan_faqs, over_word_limit, #page_title, pending_review, #possible_events, #possible_events_for_select, #post_comments, #precursor, press_industry_reports, press_releases, #preview_image, #primary_site_map, #procedure?, procedure_types, #process_product_embeds, #product_categories, #product_lines, publish_posts_due_for_publishing, published, #purge_edge_cache, ransackable_scopes, #reading_time_in_minutes, reading_time_options, #related_articles_for_display, #related_articles_for_select, #revision_article_data, #revision_content_attributes, #revision_content_digest, #revision_content_link_data, #revision_page_attributes, #revision_proposable?, #revisions, #rma_reason_code, #sanitize_urls, #schema_dot_org_images, #schema_dot_org_structure, #schema_dot_org_structure_faq, schema_types_in, #send_creation_email, #send_update_email, #seo_clicks, #seo_keywords_count, #seo_last_synced_at, #seo_metrics_synced?, #seo_top_keyword, #seo_top_position, #seo_traffic, #seo_traffic_value, #should_generate_new_friendly_id?, #site_maps, #slug_candidates, slug_find, #snapshot_revision!, #solution_text, sorted_by_most_recent_first, sorted_with_booster, states_for_select, #subject_with_type, #successor_article, #successors, #to_json_ld, #to_json_raw, #topics, types_options, types_options_for_search, #uploads, #user, #uses_revisions?, #visit_count_30d, #votes, waiting_to_publish, with_votes

Methods included from Models::EdgeCachePurgeable

#edge_cache_urls, #enqueue_edge_cache_purge, #purge_edge_cache

Methods included from Models::CrossLinkable

#case_evidence, #content_links_count, #inbound_content_links, #linked_articles, #linked_content, #linked_faqs, #linked_posts, #linked_publications, #linked_showcases, #linked_videos, #outbound_content_links

Methods included from Models::Taggable

#add_tag, all_tags, #has_tag?, normalize_tag_names, not_tagged_with, #remove_tag, #tag_list, #tag_list=, #tag_records, #taggable_type_for_tagging, tagged_with, #taggings, #tags, #tags=, tags_cloud, tags_exclude, tags_include, with_all_tags, with_any_tags, without_all_tags, without_any_tags

Methods included from Models::Embeddable

#content_embeddings, #content_for_embedding, embeddable_content_types, #embeddable_locales, #embedding_content_hash, #embedding_current?, #embedding_eligible?, embedding_partition_class, #embedding_stale?, #embedding_type_name, #embedding_vector, #find_content_embedding, #find_similar, #generate_all_embeddings!, #generate_chunked_embeddings!, #generate_embedding!, #has_embedding?, #locale_for_embedding, #needs_chunking?, regenerate_all_embeddings, semantic_search, with_shape_lock

Methods included from Models::SchemaMarkup

#add_schema, #clear_schema_markup, #consolidated_faq_page_schema, #content_has_embedded_faq_schema?, #embedded_faq_ids_from_content, #has_schema_type?, #render_schema_markup, #schema_count, #schema_types, #schemas_by_type

Methods included from Models::Auditable

#all_skipped_columns, #audit_reference_data, #creator, #should_not_save_version, #stamp_record, #updater

Methods included from Models::Lineage

#ancestors, #ancestors_ids, #children_and_roots, #descendants, #descendants_ids, #ensure_non_recursive_lineage, #family_members, #generate_full_name, #generate_full_name_array, #lineage, #lineage_array, #lineage_simple, #root, #root_id, #self_ancestors_and_descendants, #self_ancestors_and_descendants_ids, #self_and_ancestors, #self_and_ancestors_ids, #self_and_children, #self_and_descendants, #self_and_descendants_ids, #self_and_siblings, #self_and_siblings_ids, #siblings, #siblings_ids

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

Class Method Details

.active_replacement_drafts_by_source_id(source_ids) ⇒ Hash{Integer => Array<ArticleTechnical>}

Batch-loads every active direct or consolidation successor draft for the
requested approved sources. Direct replacements sort before consolidation
drafts, preserving +active_replacement_draft+'s existing preference.

Parameters:

  • source_ids (Array<Integer>)

    approved Technical Article IDs

Returns:



127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
# File 'app/models/article_technical.rb', line 127

def self.active_replacement_drafts_by_source_id(source_ids)
  ids = Array(source_ids).map(&:to_i).select(&:positive?).uniq
  return {} if ids.empty?

  marker_rows = ContentLink
                .where(
                  target_article_id: ids,
                  link_type: ContentLink::TECHNICAL_ARTICLE_WORKFLOW_LINK_TYPE,
                  context: CONSOLIDATION_SOURCE_CONTEXT
                )
                .order(:target_article_id, :source_article_id)
                .pluck(:target_article_id, :source_article_id)
  consolidation_draft_ids = marker_rows.pluck(1).uniq
  candidate_predicate = arel_table[:precursor_id].in(ids)
  candidate_predicate = candidate_predicate.or(arel_table[:id].in(consolidation_draft_ids)) if consolidation_draft_ids.any?
  active_drafts = where(state: ACTIVE_REPLACEMENT_STATES)
                  .where(candidate_predicate)
                  .order(:id)
                  .to_a
  drafts_by_id = active_drafts.index_by(&:id)
  grouped = Hash.new { |hash, source_id| hash[source_id] = [] }

  active_drafts.each do |draft|
    grouped[draft.precursor_id] << draft if draft.precursor_id.in?(ids)
  end
  marker_rows.each do |source_id, draft_id|
    draft = drafts_by_id[draft_id]
    grouped[source_id] << draft if draft && grouped[source_id].exclude?(draft)
  end
  grouped
end

Instance Method Details

#activate_reviewed_successorBoolean

Called inside the state-machine transaction immediately before approval.
The predecessor stays live throughout review, then is archived and pointed
at this successor atomically with the approval transition.

Returns:

  • (Boolean)


295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
# File 'app/models/article_technical.rb', line 295

def activate_reviewed_successor
  return activate_consolidation_sources unless precursor

  precursor.with_lock do
    if precursor.successor_article_id == id
      return true
    elsif precursor.successor_article_id.present?
      errors.add(:precursor, 'already has an approved successor')
      return false
    elsif !precursor.is_a?(ArticleTechnical) || !precursor.state.in?(APPROVED_STATES)
      errors.add(:precursor, 'must be an approved Technical Article')
      return false
    end

    precursor.archive!
    precursor.update!(successor_article: self)
  end

  true
end

#active_consolidation_draftArticleTechnical?

Returns a draft that intends to consolidate this approved source.

Returns:



207
208
209
210
211
212
# File 'app/models/article_technical.rb', line 207

def active_consolidation_draft
  self.class
      .active_replacement_drafts_by_source_id([id])
      .fetch(id, [])
      .find { |draft| draft.precursor_id != id }
end

#active_replacement_draftArticleTechnical?

Returns:



200
201
202
# File 'app/models/article_technical.rb', line 200

def active_replacement_draft
  self.class.active_replacement_drafts_by_source_id([id]).fetch(id, []).first
end

#article_type_humanString

Returns:

  • (String)


160
161
162
# File 'app/models/article_technical.rb', line 160

def article_type_human
  'Technical'
end

#clear_state_transition_validationvoid

This method returns an undefined value.



195
196
197
# File 'app/models/article_technical.rb', line 195

def clear_state_transition_validation
  @review_transition_event = nil
end

Returns the pending consolidation marker links owned by this draft.

Returns:

  • (ActiveRecord::Relation<ContentLink>)

    unconsumed source markers



217
218
219
220
221
222
# File 'app/models/article_technical.rb', line 217

def consolidation_source_links
  outbound_content_links.where(
    link_type: ContentLink::TECHNICAL_ARTICLE_WORKFLOW_LINK_TYPE,
    context: CONSOLIDATION_SOURCE_CONTEXT
  )
end

#consolidation_sourcesArray<ArticleTechnical>

Returns approved articles that this draft will supersede upon approval.

Returns:



227
228
229
230
231
# File 'app/models/article_technical.rb', line 227

def consolidation_sources
  consolidation_source_links.with_targets.filter_map do |content_link|
    content_link.target if content_link.target.is_a?(ArticleTechnical)
  end
end

#create_replacement_draft!(author:) ⇒ ArticleTechnical

Creates one in-progress successor without changing the approved source.
Repeated requests return the existing draft so the review workflow cannot
accidentally fork parallel replacements for the same live article.

Parameters:

  • author (Employee)

    employee creating and owning the replacement

Returns:

Raises:

  • (ActiveRecord::RecordInvalid)

    if this article is not approved



240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
# File 'app/models/article_technical.rb', line 240

def create_replacement_draft!(author:)
  with_lock do
    unless state.in?(APPROVED_STATES)
      errors.add(:state, 'must be published or internal before creating a replacement')
      raise ActiveRecord::RecordInvalid, self
    end

    existing = active_replacement_draft
    return existing if existing

    replacement = dup
    replacement.assign_attributes(
      state: 'draft',
      published_at: nil,
      publish_on: nil,
      archived_at: nil,
      revised_at: nil,
      slug: nil,
      slug_custom: nil,
      precursor: self,
      successor_article_id: nil,
      brafton_id: nil,
      updater_id: nil,
      original_author: author,
      user: author,
      creator_id: author&.id,
      version: version.to_i + 1
    )
    replacement.product_lines = product_lines.to_a
    replacement.product_categories = product_categories.to_a
    replacement.items = items.to_a
    replacement.uploads = uploads.to_a
    replacement.tags = tags
    replacement.save!

    outbound_content_links.ordered.with_targets.each do |content_link|
      next if content_link.workflow_managed?

      replacement.outbound_content_links.create!(
        target: content_link.target,
        link_type: content_link.link_type,
        context: content_link.context,
        created_by_type: content_link.created_by_type,
        position: content_link.position
      )
    end

    replacement
  end
end

#prepare_state_transition_validation(event) ⇒ String

Records the state-machine event so the validation can reject direct state
assignment that bypasses the review workflow.

Parameters:

  • event (String, Symbol)

    state-machine event being attempted

Returns:

  • (String)

    normalized event name



190
191
192
# File 'app/models/article_technical.rb', line 190

def prepare_state_transition_validation(event)
  @review_transition_event = event.to_s
end

#requires_editorial_review?Boolean

Returns:

  • (Boolean)


165
166
167
# File 'app/models/article_technical.rb', line 165

def requires_editorial_review?
  true
end

#review_assigneeEmployee?

+Article#user+ is the mutable current owner, while +original_author+ keeps
immutable authorship. Technical Articles use that existing owner as the
review assignee; older/unassigned records fall back to their author.

Returns:

  • (Employee, nil)

    the explicit reviewer or original author



174
175
176
# File 'app/models/article_technical.rb', line 174

def review_assignee
  user || original_author
end

#review_assignee_idInteger?

Returns the effective review assignee without loading the association.

Returns:

  • (Integer, nil)

    the explicit reviewer id or original author id



181
182
183
# File 'app/models/article_technical.rb', line 181

def review_assignee_id
  user_id || original_author_id
end