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

Constants included from Models::Auditable

Models::Auditable::ALWAYS_IGNORED

Constants included from Schedulable

Schedulable::SIMPLE_FORM_OPTIONS

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 Schedulable

config

Methods included from Models::AfterCommittable

#after_commit

Methods included from Models::EventPublishable

#publish_event

Instance Attribute Details

#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



191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
# File 'app/models/post.rb', line 191

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:



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

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



168
169
170
171
172
173
174
175
176
177
178
179
# File 'app/models/post.rb', line 168

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



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

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

.tag_title(tag) ⇒ Object



187
188
189
# File 'app/models/post.rb', line 187

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:



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

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



164
165
166
# File 'app/models/post.rb', line 164

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



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
279
280
281
# File 'app/models/post.rb', line 236

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_dupObject



131
132
133
134
135
136
137
138
139
# File 'app/models/post.rb', line 131

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.



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

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

#embedding_type_nameObject



305
306
307
# File 'app/models/post.rb', line 305

def embedding_type_name
  'Post'
end

#legacy_sourceSource

Returns:

See Also:



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

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.



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

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

#locale_for_embeddingObject



289
290
291
# File 'app/models/post.rb', line 289

def locale_for_embedding
  'en-US'
end

#main_public_tagObject



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

def main_public_tag
  return if tags.blank?

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

#next_postObject



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

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:



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

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

#preview_imageImage

Returns:

See Also:



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

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

#previous_postObject



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

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

#primary_tagObject



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

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



294
295
296
297
298
299
300
301
302
303
# File 'app/models/post.rb', line 294

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:



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

belongs_to :source, optional: true

#to_sObject



221
222
223
# File 'app/models/post.rb', line 221

def to_s
  subject
end

#user_name(_skip_link = false) ⇒ Object



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

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