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 =
1422
COMMENT_COUNT_SUBQUERY =
'select count(pc.id) from post_comments pc where pc.post_id = articles.id'
COMMENT_COUNT_SUBQUERY_LAST7D =
%(#{COMMENT_COUNT_SUBQUERY} AND pc.created_at >= current_date - interval '7 days')
POST_TAGS =
%w[company-news countertop-heaters design-trends example-projects general-information heat-tape-for-pipes indoor-heating installation led-mirrors mirror-defoggers outdoor-heating press-industry-report press-release product-information
radiant-floor-heating radiant-panels remodeling roof-and-gutter-deicing troubleshooting share-your-story shower-kits snow-melting towel-warmers trade-professionals].freeze

Constants inherited from Article

Article::FAQ_MAX_WORDS, Article::FAQ_TARGET_WORDS, Article::FAQ_WARN_WORDS, Article::FAQ_WORD_COUNT_SQL, Article::STI_TYPES

Constants included from Models::Embeddable

Models::Embeddable::DEFAULT_MODEL, Models::Embeddable::MAX_CONTENT_LENGTH

Constants included from Models::Auditable

Models::Auditable::ALWAYS_IGNORED

Instance Attribute Summary collapse

Attributes inherited from Article

#schema_markup, #serial_number_high_range, #serial_number_low_range, #subject

Belongs to collapse

Methods inherited from Article

#active_revision, #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

all_product_lines, #applicable_product_category_urls, #applicable_product_line_urls, archived, #article_type_human, #author, available_schema_types, #breadcrumbs_hash, drafts, due_for_publishing, #effective_published_date, #effective_revised_date, #email_url, embeddable_content_types, embedded_in_posts, #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, #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?, options_for_select, orphan_faqs, over_word_limit, #page_title, #possible_events, #possible_events_for_select, 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, #restore_revision!, #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::CrossLinkable

#content_links_count, #linked_content, #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_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

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::EventPublishable

#publish_event

Instance Attribute Details

#auto_update_revised_atObject

Returns the value of attribute auto_update_revised_at.



103
104
105
# File 'app/models/post.rb', line 103

def auto_update_revised_at
  @auto_update_revised_at
end

#revision_change_notesObject

Returns the value of attribute revision_change_notes.



103
104
105
# File 'app/models/post.rb', line 103

def revision_change_notes
  @revision_change_notes
end

Class Method Details



186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
# File 'app/models/post.rb', line 186

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',
          '/radiant-heat-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:



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

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_selectObject



163
164
165
166
167
168
169
170
171
172
173
174
# File 'app/models/post.rb', line 163

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('LOWER(tags.name) IN (?)', POST_TAGS.map(&:downcase))
                       .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



212
213
214
# File 'app/models/post.rb', line 212

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

.tag_title(tag) ⇒ Object



182
183
184
# File 'app/models/post.rb', line 182

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:



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

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_bioObject



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

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



231
232
233
234
235
236
237
238
239
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
# File 'app/models/post.rb', line 231

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
  if related.any?
    parts << "Related articles: #{related.map(&:subject).compact.join(', ')}"
  end

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

#deep_dupObject



126
127
128
129
130
131
132
133
134
# File 'app/models/post.rb', line 126

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.



282
283
284
# File 'app/models/post.rb', line 282

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

#embedding_type_nameObject



302
303
304
# File 'app/models/post.rb', line 302

def embedding_type_name
  'Post'
end

#legacy_sourceSource

Returns:

See Also:



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

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

#liquid_variable_context(_locale, _preview_mode) ⇒ Object

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.



139
140
141
# File 'app/models/post.rb', line 139

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

#locale_for_embeddingObject



286
287
288
# File 'app/models/post.rb', line 286

def locale_for_embedding
  'en-US'
end

#main_public_tagObject



176
177
178
179
180
# File 'app/models/post.rb', line 176

def main_public_tag
  return if tags.blank?

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

#next_postObject



147
148
149
# File 'app/models/post.rb', line 147

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

#post_commentsActiveRecord::Relation<PostComment>

Returns:

See Also:



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

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

#preview_imageImage

Returns:

See Also:



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

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

#previous_postObject



143
144
145
# File 'app/models/post.rb', line 143

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

#primary_tagObject



151
152
153
# File 'app/models/post.rb', line 151

def primary_tag
  tags&.first
end

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

Public: invoked from controllers to purge cache



291
292
293
294
295
296
297
298
299
300
# File 'app/models/post.rb', line 291

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

Returns:

See Also:



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

belongs_to :source, optional: true

#to_sObject



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

def to_s
  subject
end

#user_name(_skip_link = false) ⇒ Object



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

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