Class: Post

Inherits:
Article show all
Includes:
Models::LiquidMethods
Defined in:
app/models/post.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)

Constant Summary collapse

PARENT_SOURCE_ID =

Parent source id.

1422
COMMENT_COUNT_SUBQUERY =

Comment count subquery.

'select count(pc.id) from post_comments pc where pc.post_id = articles.id'
COMMENT_COUNT_SUBQUERY_LAST7D =

Comment count subquery last7d.

%(#{COMMENT_COUNT_SUBQUERY} AND pc.created_at >= current_date - interval '7 days').freeze
POST_TAGS =

Recognised post tags.

%w[company-news countertop-heaters design-trends example-projects general-information heat-tape-for-pipes indoor-heating installation led-mirrors mirror-defoggers newsletter outdoor-heating press-industry-report press-release product-information
radiant-floor-heating infrared-heating-panels remodeling roof-and-gutter-deicing troubleshooting share-your-story shower-kits snow-melting towel-warmers trade-professionals].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 collapse

Attributes inherited from Article

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

Belongs to collapse

Methods inherited from Article

#original_author, #precursor, #rma_reason_code, #successor_article, #user

Methods included from Models::Auditable

#creator, #updater

Has one collapse

Has many collapse

Methods inherited from Article

#article_pages, #embedded_assets, #item_product_lines, #revisions, #site_maps, #successors, #votes

Methods included from Models::CrossLinkable

#inbound_content_links, #outbound_content_links

Methods included from Models::Taggable

#tag_records, #taggings

Methods included from Models::Embeddable

#content_embeddings

Class Method Summary collapse

Instance Method Summary collapse

Methods inherited from Article

#activate_reviewed_successor, all_product_lines, #applicable_product_category_urls, #applicable_product_line_urls, archived, #article_type_human, #author, available_schema_types, #breadcrumbs_hash, #build_revision_draft, #clear_state_transition_validation, #create_revision_draft!, delete_content_embeddings_for, drafts, due_for_publishing, #effective_published_date, #effective_revised_date, #email_url, embeddable_content_types, embedded_in_posts, embedding_eligible, #embedding_eligible?, #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, #items, knowledge_corpus, #knowledge_corpus?, #knowledge_role_human, knowledge_roles_for_select, #latest_revision, #link_checks, #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, orphan_faqs, over_word_limit, #page_title, pending_review, #possible_events, #possible_events_for_select, #prepare_state_transition_validation, press_industry_reports, press_releases, #primary_site_map, #procedure?, procedure_types, #process_product_embeds, #product_categories, #product_lines, publish_posts_due_for_publishing, published, ransackable_scopes, #reading_time_in_minutes, reading_time_options, #related_articles_for_display, #related_articles_for_select, #requires_editorial_review?, #revision_article_data, #revision_content_attributes, #revision_content_digest, #revision_content_link_data, #revision_page_attributes, #revision_proposable?, #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?, #slug_candidates, slug_find, #snapshot_revision!, #solution_text, sorted_by_most_recent_first, sorted_with_booster, states_for_select, #subject_with_type, #to_json_ld, #to_json_raw, #topics, types_options, types_options_for_search, #uploads, #uses_revisions?, #visit_count_30d, waiting_to_publish, with_votes

Methods included from Models::EdgeCachePurgeable

#edge_cache_urls, #enqueue_edge_cache_purge

Methods included from Models::CrossLinkable

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

Methods included from Models::Taggable

#add_tag, all_tags, #has_tag?, normalize_tag_names, not_tagged_with, #remove_tag, #tag_list, #tag_list=, #taggable_type_for_tagging, tagged_with, #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

embeddable_content_types, #embedding_content_hash, #embedding_current?, #embedding_eligible?, embedding_partition_class, #embedding_stale?, #embedding_vector, #find_content_embedding, #find_similar, #generate_all_embeddings!, #generate_chunked_embeddings!, #generate_embedding!, #has_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, #should_not_save_version, #stamp_record

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

Instance Attribute Details

#applying_revision_idObject

Returns the value of attribute applying_revision_id.



108
109
110
# File 'app/models/post.rb', line 108

def applying_revision_id
  @applying_revision_id
end

#auto_update_revised_atObject

Returns the value of attribute auto_update_revised_at.



108
109
110
# File 'app/models/post.rb', line 108

def auto_update_revised_at
  @auto_update_revised_at
end

#revision_change_notesObject

Returns the value of attribute revision_change_notes.



108
109
110
# File 'app/models/post.rb', line 108

def revision_change_notes
  @revision_change_notes
end

Class Method Details

Available breadcrumb paths as select-box options.

Returns:

  • (Array<Array(String, String)>)


222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
# File 'app/models/post.rb', line 222

def self.breadcrumbs_select_option
  list = ['/floor-heating',
          '/floor-heating/tile-marble-or-stone',
          '/floor-heating/carpet',
          '/floor-heating/concrete',
          '/floor-heating/underlayment',
          '/snow-melting',
          '/snow-melting/heated-driveway',
          '/snow-melting/accessibility-ramp-deicing',
          '/snow-melting/asphalt-design-guide',
          '/snow-melting/outdoor-stairs',
          '/snow-melting/heated-concrete-patio',
          '/roof-and-gutter-deicing',
          '/pipe-freeze-protection',
          '/towel-warmer',
          '/infrared-heating-panels',
          '/led-mirror',
          '/mirror-defogger',
          '/countertop-heater']
  list += Post.distinct.pluck('unnest(breadcrumbs)'.sql_safe)
  list.uniq.compact.sort
end

.by_date_rangeActiveRecord::Relation<Post>

A relation of Posts that are by date range. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Post>)

See Also:



120
# File 'app/models/post.rb', line 120

scope :by_date_range, ->(date_start, date_end) { where(Post[:published_at].gteq(date_start)).where(Post[:published_at].lteq(date_end)) }

.category_tags_for_selectArray<Array(String, String)>

Allowed tags actually used by published posts, as [title, slug] pairs
suitable for a select box.

Returns:

  • (Array<Array(String, String)>)


192
193
194
195
196
197
198
199
200
201
202
203
# File 'app/models/post.rb', line 192

def self.category_tags_for_select
  # Get all tags from published posts that are in the allowed POST_TAGS list
  # Use explicit join with taggable_type = 'Post' to handle STI properly
  published_tags = Post.published
                       .joins("INNER JOIN taggings ON taggings.taggable_id = articles.id AND taggings.taggable_type = 'Post'")
                       .joins('INNER JOIN tags ON tags.id = taggings.tag_id')
                       .where(tags: { name: POST_TAGS })
                       .distinct
                       .pluck('tags.name')
  published_tags = published_tags.map(&:downcase).uniq & POST_TAGS
  published_tags.sort.map { |tag| [tag_title(tag), tag] }
end

.purge_all_posts_by_tagObject

Class method to purge all posts using tag-based purging
This is more efficient than URL-based purging when you want to purge all posts
since it makes a single API call to Cloudflare instead of multiple calls for each URL



248
249
250
# File 'app/models/post.rb', line 248

def self.purge_all_posts_by_tag
  EdgeCacheWorker.perform_async('tags' => ['post'])
end

.tag_title(tag) ⇒ String

Human-readable title for a tag slug.

Parameters:

  • tag (String)

    tag slug

Returns:

  • (String)


216
217
218
# File 'app/models/post.rb', line 216

def self.tag_title(tag)
  tag == 'led-mirrors' ? 'LED Mirrors' : tag.titleize
end

.with_comments_countActiveRecord::Relation<Post>

A relation of Posts that are with comments count. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Post>)

See Also:



121
122
123
# File 'app/models/post.rb', line 121

scope :with_comments_count, -> {
  all.select_append("(#{COMMENT_COUNT_SUBQUERY}) as comments_count, (#{COMMENT_COUNT_SUBQUERY_LAST7D}) as comments_count_last7d")
}

Instance Method Details

#author_bioString?

Author bio pulled from the author's employee record.

Returns:

  • (String, nil)


185
186
187
# File 'app/models/post.rb', line 185

def author_bio
  author&.employee_record&.author_bio&.presence
end

#content_for_embedding(_content_type = :primary, locale: 'en-US') ⇒ Object

Override Article's content_for_embedding for blog post specifics.

Content is rendered through Liquid (resolving {% image %}, {% faq %}, {% video %},
conditionals like {% if canada %}, etc.) before being stripped to plain text.
This ensures the embedding reflects what visitors actually read, including
locale-specific copy.

Parameters:

  • _content_type (Symbol) (defaults to: :primary)

    Ignored — Posts use a single :primary embedding type

  • locale (String) (defaults to: 'en-US')

    BCP-47 locale tag used to evaluate Liquid conditionals
    (e.g. 'en-US' renders USA-specific copy; 'en-CA' renders Canadian copy).
    Defaults to 'en-US'.



269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
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
311
312
313
314
# File 'app/models/post.rb', line 269

def content_for_embedding(_content_type = :primary, locale: 'en-US')
  locale_str = locale.to_s
  canada     = %w[en-CA fr-CA].include?(locale_str)
  usa        = locale_str == 'en-US'

  parts = []

  parts << 'Type: Blog Post'
  parts << "Title: #{subject}"        if subject.present?
  parts << "Subtitle: #{title}"       if title.present?

  # Render description through Liquid so any locale-conditional copy is resolved
  if description.present?
    rendered_desc = Liquid::ParseEnvironment.parse(description).render(
      'locale' => locale_str, 'canada' => canada, 'usa' => usa
    )
    parts << parse_text_from_html(rendered_desc)
  end

  # Use localized_solution (already in Article) which runs Liquid rendering,
  # price localization, and product-embed resolution for the given locale.
  # This is the same path the frontend uses, so the embedding matches what
  # visitors actually read.
  rendered_body = localized_solution(locale_str)
  parts << parse_text_from_html(rendered_body) if rendered_body.present?

  # Product line hierarchy for SEO/topic context
  if product_lines.any?
    pl_names = product_lines.map(&:lineage_expanded).join(', ')
    parts << "Topics: #{pl_names}"
  end

  # Tags for blog categorisation
  parts << "Categories: #{tags.join(', ')}" if tags.present?

  # Author attribution
  parts << "Author: #{author&.full_name}" if author&.full_name.present?

  # Related articles — rendered as a "Related posts" widget at the bottom of the page.
  # Including their titles helps the embedding understand the semantic neighbourhood
  # of this post, which in turn improves cross-link recommendations.
  related = related_articles_for_display
  parts << "Related articles: #{related.filter_map(&:subject).join(', ')}" if related.any?

  parts.compact.join("\n\n")
end

#deep_dupPost

Duplicates this post as a draft, prefixing title/subject with "Copy of"
and clearing published/slug fields.

Returns:

  • (Post)

    the unsaved duplicate



138
139
140
141
142
143
144
145
146
# File 'app/models/post.rb', line 138

def deep_dup
  deep_clone(except: %i[published_at created_at updated_at slug_custom]) do |original, copy|
    if copy.is_a?(Post)
      copy.title = "Copy of #{original.title}"
      copy.state = 'draft'
      copy.subject = "Copy of #{original.subject}"
    end
  end
end

#embeddable_localesObject

Generate per-locale embeddings so locale-conditional Liquid content
(Canada vs. USA copy, localised pricing) is encoded separately.



318
319
320
# File 'app/models/post.rb', line 318

def embeddable_locales
  %w[en-US en-CA]
end

#embedding_type_nameString

Embedding type label for this record.

Returns:

  • (String)


346
347
348
# File 'app/models/post.rb', line 346

def embedding_type_name
  'Post'
end

#legacy_sourceSource

Legacy source record matched by subject/name for migrated content.

Returns:

See Also:



116
# File 'app/models/post.rb', line 116

has_one :legacy_source, primary_key: :subject, foreign_key: :name, class_name: 'Source', inverse_of: :post, dependent: :nullify

#liquid_variable_context(_locale, _preview_mode) ⇒ Hash

Expose a PostDrop as the 'post' variable in Liquid templates rendered
inside this post's solution/description fields.
Allows blog content to reference {{ post.subject }}, {{ post.tags }}, etc.

Parameters:

  • _locale (String)

    locale being rendered (unused)

  • _preview_mode (Boolean)

    whether rendering in preview mode (unused)

Returns:

  • (Hash)

    Liquid variable assignments



154
155
156
# File 'app/models/post.rb', line 154

def liquid_variable_context(_locale, _preview_mode)
  { 'post' => Liquid::PostDrop.new(self) }
end

#locale_for_embeddingString

Locale used for the default (non-per-locale) embedding.

Returns:

  • (String)


324
325
326
# File 'app/models/post.rb', line 324

def locale_for_embedding
  'en-US'
end

#main_public_tagString?

The first of this post's tags that appears in the public POST_TAGS list.

Returns:

  • (String, nil)


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

def main_public_tag
  return if tags.blank?

  tags.find { |t| t.in?(POST_TAGS) }
end

#next_postPost?

The next published post after this one.

Returns:



166
167
168
# File 'app/models/post.rb', line 166

def next_post
  self.class.published.where(Post[:published_at].gt(published_at)).order(:published_at).first
end

#post_commentsActiveRecord::Relation<PostComment>

Comments left on this post, oldest first.

Returns:

See Also:



118
# File 'app/models/post.rb', line 118

has_many :post_comments, -> { order(:created_at) }, dependent: :destroy, inverse_of: :post

#preview_imageImage

Optional image used as the post's preview thumbnail.

Returns:

See Also:



111
# File 'app/models/post.rb', line 111

belongs_to :preview_image, class_name: 'Image', optional: true

#previous_postPost?

The most recent published post before this one.

Returns:



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

def previous_post
  self.class.published.where(Post[:published_at].lt(published_at)).order(:published_at).reverse_order.first
end

#primary_tagString?

The first tag on this post.

Returns:

  • (String, nil)


172
173
174
# File 'app/models/post.rb', line 172

def primary_tag
  tags&.first
end

#purge_edge_cache(include_indirect_associations: true, extra_urls: [], prepare_sitemap: true) ⇒ void

This method returns an undefined value.

Public: invoked from controllers to purge cache

Parameters:

  • include_indirect_associations (Boolean) (defaults to: true)

    also purge associated records' URLs

  • extra_urls (Array<String>) (defaults to: [])

    additional URLs to purge

  • prepare_sitemap (Boolean) (defaults to: true)

    insert this post into the sitemap first



333
334
335
336
337
338
339
340
341
342
# File 'app/models/post.rb', line 333

def purge_edge_cache(include_indirect_associations: true, extra_urls: [], prepare_sitemap: true)
  # Ensures the sitemap is populated with this post
  insert_in_sitemap if prepare_sitemap
  # Blog post will also reset the post index
  extra_urls ||= []
  extra_urls += SiteMap.where("path LIKE '%/posts'").map(&:url)
  # And all the tag pages
  extra_urls += tags.flat_map { |tag| SiteMap.where(SiteMap[:path].matches("%/posts/#{tag}/tag")).map(&:url) }
  super(include_indirect_associations:, extra_urls:)
end

#sourceSource

Optional source record describing where this content originated.

Returns:

See Also:



113
# File 'app/models/post.rb', line 113

belongs_to :source, optional: true

#to_sString

String representation of the post.

Returns:

  • (String)

    the subject



254
255
256
# File 'app/models/post.rb', line 254

def to_s
  subject
end

#user_name(_skip_link = false) ⇒ String

Display name of the post's author.

Parameters:

  • _skip_link (Boolean) (defaults to: false)

    unused; kept for interface parity

Returns:

  • (String)


179
180
181
# File 'app/models/post.rb', line 179

def user_name(_skip_link = false)
  author&.full_name || 'N/A'
end