Class: ReviewsIo
- Inherits:
-
ApplicationRecord
- Object
- ActiveRecord::Base
- ApplicationRecord
- ReviewsIo
- Includes:
- Models::Embeddable
- Defined in:
- app/models/reviews_io.rb
Overview
ReviewsIo model stores product reviews imported from Reviews.io API.
Also provides a unified interface for fetching review data (to be deprecated).
Table: reviews_io (singular name to match company branding)
Usage for stored reviews:
ReviewsIo.active.where(sku: 'PROD-123')
ReviewsIo.product_reviews.where(rating: 5)
Legacy API methods (to be deprecated):
ReviewsIo.fetch_reviews(sku: 'PROD-123', page: 1, limit: 5)
ReviewsIo.fetch_reviews_for_item(item, page: 1, limit: 10)
== Schema Information
Table name: reviews_io
Database name: primary
id :bigint not null, primary key
author_email :string
author_location :string
author_name :string
comments :text
content_changed_at :datetime
custom_headline :string
deleted_at :datetime
hidden :boolean default(FALSE), not null
last_seen_at :datetime
photos :jsonb
rating :integer
raw_api_data :jsonb
replies :jsonb
review_attributes :jsonb
review_date :datetime
review_type :string
sku :string
status :string default("active"), not null
tags :jsonb
title :string
verified_buyer :boolean default(FALSE), not null
videos :jsonb
created_at :datetime not null
updated_at :datetime not null
avatar_image_id :integer
invitation_id :string
order_id :string
reviews_io_id :string not null
reviewsio_user_id :string
store_branch_id :string
Indexes
index_reviews_io_on_avatar_image_id (avatar_image_id)
index_reviews_io_on_last_seen_at (last_seen_at)
index_reviews_io_on_photos_gin (photos) USING gin
index_reviews_io_on_rating (rating)
index_reviews_io_on_review_type (review_type)
index_reviews_io_on_reviews_io_id (reviews_io_id) UNIQUE
index_reviews_io_on_sku (sku)
index_reviews_io_on_status (status)
index_reviews_io_on_verified_buyer (verified_buyer)
index_reviews_io_on_videos_gin (videos) USING gin
Foreign Keys
fk_rails_... (avatar_image_id => digital_assets.id)
Constant Summary collapse
- STATUS_ACTIVE =
Statuses
'active'- STATUS_DELETED =
Status deleted.
'deleted'- EXTRA_CANONICAL_TAGS =
Additional canonical tags that should always appear in the CRM tag picker
and are not derived from CMS page ids (e.g. "for-homepage" has no page file). %w[for-homepage].freeze
- AVATAR_AI_WARDROBE_HINTS =
Rotating wardrobe cues for Gemini avatar prompts — reduces same-outfit bias when
+variety_seed+ (e.g. review id) is passed from +avatar_ai_image_prompt+. [ 'soft crew-neck sweater in a solid muted color', 'simple cotton tee with an open casual jacket', 'light knit cardigan over a plain top', 'clean neckline, solid-tone blouse or woven shirt, minimal accessories', 'casual quarter-zip or pullover in a flat color, no patterns', 'relaxed hoodie in one flat color', 'modern understated collared shirt, smart-casual', 'lightweight solid-color top suited to mild or warm weather', 'fine-gauge knit in a neutral palette', 'plain shirt with a simple vest or light outer layer if outdoors' ].freeze
- AVATAR_AI_AGE_PRESENTATION_HINTS =
Rotating apparent-age cues (uncorrelated from wardrobe via different seed mixing).
[ 'apparent age in their 20s or early 30s', 'apparent age in their mid-30s to 40s', 'apparent age in their 50s', 'apparent age 60 or older' ].freeze
- AVATAR_AI_HERITAGE_PRESENTATION_HINTS =
Rotating respectful heritage / presentation cues — widens ethnicity and skin-tone variety.
[ 'a person whose features and skin tone read as Black or of African diaspora heritage', 'a person whose features and skin tone read as East Asian or Southeast Asian heritage', 'a person whose features and skin tone read as South Asian heritage', 'a person whose features and skin tone read as Latino or Hispanic heritage', 'a person whose features and skin tone read as Middle Eastern, North African, or Southwest Asian heritage', 'a person whose features and skin tone read as Indigenous to the Americas', 'a person whose features and skin tone read as Pacific Islander heritage', 'a person whose features and skin tone read as white or of European heritage', 'a person with multiracial or ethnically blended features' ].freeze
- CRM_TAG_PREFIX =
Tags with this prefix are managed in the CRM (page-targeting, homepage, etc.)
and must be preserved during API syncs — the Reviews.io API is unaware of them. 'for-'
Constants included from Models::Embeddable
Models::Embeddable::MAX_CONTENT_LENGTH
Constants included from Schedulable
Schedulable::SIMPLE_FORM_OPTIONS
Instance Attribute Summary collapse
-
#reviews_io_id ⇒ Object
readonly
Validations.
- #status ⇒ Object readonly
Has many collapse
- #images ⇒ ActiveRecord::Relation<Image>
- #imported_videos ⇒ ActiveRecord::Relation<ImportedVideo>
-
#reviews_io_images ⇒ ActiveRecord::Relation<ReviewsIoImage>
Associations.
- #reviews_io_videos ⇒ ActiveRecord::Relation<ReviewsIoVideo>
Methods included from Models::Embeddable
Belongs to collapse
Class Method Summary collapse
-
.active ⇒ ActiveRecord::Relation<ReviewsIo>
A relation of ReviewsIos that are active.
-
.all_tags ⇒ Array<String>
Returns all unique tags for the CRM tag picker: • Page-targeting tags derived from CMS pages (filesystem walk, fast, constant per process).
-
.avatar_ai_image_prompt(name:, profile_name: nil, location: nil, variety_seed: nil) ⇒ Object
Full prompt for CRM "Generate Avatar" and +ReviewAvatarBackfillWorker+.
-
.batch_stats_by_sku(skus) ⇒ Hash
Get review stats per SKU in a single query (batch method).
-
.by_rating ⇒ ActiveRecord::Relation<ReviewsIo>
A relation of ReviewsIos that are by rating.
-
.by_sku ⇒ ActiveRecord::Relation<ReviewsIo>
A relation of ReviewsIos that are by sku.
-
.by_skus ⇒ ActiveRecord::Relation<ReviewsIo>
A relation of ReviewsIos that are by skus.
-
.by_tag ⇒ ActiveRecord::Relation<ReviewsIo>
A relation of ReviewsIos that are by tag.
-
.company_wide_stats ⇒ Hash
Company-wide stats across all visible reviews (store + product).
-
.configured? ⇒ Boolean
Check if Reviews.io is configured and available.
-
.deleted ⇒ ActiveRecord::Relation<ReviewsIo>
A relation of ReviewsIos that are deleted.
-
.embeddable ⇒ ActiveRecord::Relation<ReviewsIo>
A relation of ReviewsIos that are embeddable.
-
.embeddable_content_types ⇒ Object
Content types that can be embedded.
-
.exclude_tags ⇒ ActiveRecord::Relation<ReviewsIo>
A relation of ReviewsIos that are exclude tags.
-
.fetch_reviews(sku:, page: 1, limit: 5, order: 'date_desc', include_item_name: false) ⇒ Hash
Fetch reviews for a specific SKU from the local database.
-
.fetch_reviews_by_tag(tag:, page: 1, limit: 5, order: 'date_desc') ⇒ Hash
Fetch reviews by tag from the local database.
-
.fetch_reviews_for_item(item, page: 1, limit: 5, order: 'date_desc') ⇒ Hash
Fetch reviews for an Item instance.
-
.fetch_reviews_for_skus(skus, page: 1, limit: 5, order: 'date_desc') ⇒ Hash
Fetch reviews for multiple SKUs (useful for product line pages).
-
.hidden_reviews ⇒ ActiveRecord::Relation<ReviewsIo>
A relation of ReviewsIos that are hidden reviews.
-
.high_rated ⇒ ActiveRecord::Relation<ReviewsIo>
A relation of ReviewsIos that are high rated.
-
.mark_stale_as_deleted(import_time) ⇒ Integer
Mark reviews not seen in this import as deleted.
-
.merge_tags_with_crm(existing_tags, api_tags) ⇒ Array<String>
Merge API-sourced tags with CRM-managed tags already on the record.
-
.needs_sync ⇒ ActiveRecord::Relation<ReviewsIo>
A relation of ReviewsIos that are needs sync.
-
.product_reviews ⇒ ActiveRecord::Relation<ReviewsIo>
A relation of ReviewsIos that are product reviews.
-
.ransackable_attributes(_auth_object = nil) ⇒ Object
Ransack configuration.
- .ransackable_scopes(_auth_object = nil) ⇒ Object
-
.rating_snapshot(sku) ⇒ Hash
Get rating snapshot for a SKU (average rating and count only).
-
.recent ⇒ ActiveRecord::Relation<ReviewsIo>
A relation of ReviewsIos that are recent.
-
.reviews_for_product_line(product_line, page: 1, per_page: nil, min_length: nil) ⇒ Hash
Get full review data for a product line (compatible with Rating class).
-
.skus_for_product_line(product_line) ⇒ Array<String>
Get SKUs for a product line.
-
.stats_for_product_line(product_line) ⇒ Hash
Get review stats (average rating and count) for a product line.
-
.stats_for_product_lines(product_lines) ⇒ Hash
Batch method: Get review stats for multiple product lines in fewer queries.
-
.stats_for_skus(skus) ⇒ Hash
Get review stats for specific SKUs (aggregated).
-
.store_review_stats ⇒ Hash
Get store review stats (average rating and count).
-
.store_reviews ⇒ ActiveRecord::Relation<ReviewsIo>
A relation of ReviewsIos that are store reviews.
-
.tags_include ⇒ ActiveRecord::Relation<ReviewsIo>
A relation of ReviewsIos that are tags include.
-
.upsert_from_api(review_data, import_time: Time.current) ⇒ ReviewsIo
Upsert a review from Reviews.io API response.
-
.verified ⇒ ActiveRecord::Relation<ReviewsIo>
A relation of ReviewsIos that are verified.
-
.visible ⇒ ActiveRecord::Relation<ReviewsIo>
A relation of ReviewsIos that are visible.
-
.with_comments ⇒ ActiveRecord::Relation<ReviewsIo>
A relation of ReviewsIos that are with comments.
-
.with_imported_images ⇒ ActiveRecord::Relation<ReviewsIo>
A relation of ReviewsIos that are with imported images.
-
.with_imported_videos ⇒ ActiveRecord::Relation<ReviewsIo>
A relation of ReviewsIos that are with imported videos.
-
.with_photos ⇒ ActiveRecord::Relation<ReviewsIo>
A relation of ReviewsIos that are with photos.
-
.with_videos ⇒ ActiveRecord::Relation<ReviewsIo>
A relation of ReviewsIos that are with videos.
-
.without_imported_images ⇒ ActiveRecord::Relation<ReviewsIo>
A relation of ReviewsIos that are without imported images.
-
.without_imported_videos ⇒ ActiveRecord::Relation<ReviewsIo>
A relation of ReviewsIos that are without imported videos.
Instance Method Summary collapse
-
#active? ⇒ Boolean
Instance methods.
-
#all_photos_imported? ⇒ Boolean
Callers iterating over multiple records should preload associations: ReviewsIo.includes(:reviews_io_images, :reviews_io_videos).
- #all_videos_imported? ⇒ Boolean
-
#content_for_embedding(_content_type = :primary) ⇒ Object
Generate content for semantic search embedding Includes the review text plus product context.
- #deleted? ⇒ Boolean
-
#embedding_content_changed? ⇒ Boolean
Detect if content changed (for automatic re-embedding).
-
#full_review ⇒ Object
Alias comments to full_review for view compatibility.
-
#has_media? ⇒ Boolean
Returns true if the review has any media (photos or videos).
-
#has_photos? ⇒ Boolean
Returns true if the review has any photos — checks both the photos column and photos_raw in raw_api_data (the API sometimes populates one but not the other).
-
#has_videos? ⇒ Boolean
Returns true if the review has any video assets.
-
#headline_review ⇒ Object
Returns the headline used on review cards and in schema.org markup.
- #mark_as_deleted! ⇒ Object
- #mark_as_seen! ⇒ Object
-
#obfuscated_reviewer_name ⇒ Object
Return obfuscated name for privacy (matches ReviewProxy behavior).
-
#photo_urls ⇒ Object
Extracts photo URLs from photos column first, falling back to photos_raw in raw_api_data.
-
#reviewer_city ⇒ Object
Extract city from author_location (format: "City, State" or "City, Country").
-
#reviewer_name ⇒ Object
Accessor for reviewer_name (alias of author_name).
-
#reviewer_state ⇒ Object
Extract state from author_location.
-
#star_rating_level ⇒ Object
Alias rating to star_rating_level for view compatibility.
-
#video_urls ⇒ Object
Extracts video URLs from the videos column or raw_api_data.
Methods included from Models::Embeddable
#embeddable_locales, #embedding_content_hash, 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
Methods inherited from ApplicationRecord
ransackable_associations, ransortable_attributes, #to_relation
Methods included from Schedulable
Methods included from Models::AfterCommittable
Methods included from Models::EventPublishable
Instance Attribute Details
#reviews_io_id ⇒ Object (readonly)
Validations
Validations:
399 |
# File 'app/models/reviews_io.rb', line 399 validates :reviews_io_id, presence: true, uniqueness: true |
#status ⇒ Object (readonly)
400 |
# File 'app/models/reviews_io.rb', line 400 validates :status, presence: true, inclusion: { in: [STATUS_ACTIVE, STATUS_DELETED] } |
Class Method Details
.active ⇒ ActiveRecord::Relation<ReviewsIo>
A relation of ReviewsIos that are active. Active Record Scope
131 |
# File 'app/models/reviews_io.rb', line 131 scope :active, -> { where(status: STATUS_ACTIVE) } |
.all_tags ⇒ Array<String>
Returns all unique tags for the CRM tag picker:
• Page-targeting tags derived from CMS pages (filesystem walk, fast, constant per process).
• Extra canonical tags not derivable from page ids (e.g. "for-homepage").
• Any ad-hoc tags already assigned to reviews in the DB.
Page tags sort first; ad-hoc DB tags follow alphabetically.
185 186 187 188 189 190 191 192 193 194 195 196 197 |
# File 'app/models/reviews_io.rb', line 185 def self. = DigitalAsset. + EXTRA_CANONICAL_TAGS sql = <<~SQL.squish SELECT DISTINCT tag FROM reviews_io, jsonb_array_elements_text(tags) AS tag WHERE tags IS NOT NULL AND jsonb_array_length(tags) > 0 ORDER BY tag SQL = connection.select_values(sql) ( + ).uniq.sort_by { |t| [.include?(t) ? 0 : 1, t] } end |
.avatar_ai_image_prompt(name:, profile_name: nil, location: nil, variety_seed: nil) ⇒ Object
Full prompt for CRM "Generate Avatar" and +ReviewAvatarBackfillWorker+.
+variety_seed+ (e.g. +review.id+) picks a rotating wardrobe hint so batches are less samey.
587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 |
# File 'app/models/reviews_io.rb', line 587 def self.avatar_ai_image_prompt(name:, profile_name: nil, location: nil, variety_seed: nil) wardrobe_line = age_line = heritage_line = nil if variety_seed.present? s = variety_seed.to_i.abs wardrobe_line = "For this portrait specifically, use: #{AVATAR_AI_WARDROBE_HINTS[s % AVATAR_AI_WARDROBE_HINTS.size]}." age_line = "For this portrait, aim for #{AVATAR_AI_AGE_PRESENTATION_HINTS[(s / 3) % AVATAR_AI_AGE_PRESENTATION_HINTS.size]}." heritage_line = "Depict #{AVATAR_AI_HERITAGE_PRESENTATION_HINTS[(s / 7) % AVATAR_AI_HERITAGE_PRESENTATION_HINTS.size]} — " \ 'authentic and respectful, never caricature or stereotype.' end [ "Photorealistic portrait-style avatar of a person named #{name}.", profile_name.present? ? "They are a #{profile_name}." : nil, location.present? ? "They are located in #{location}." : nil, 'Warm, friendly expression. Square crop, centered face.', 'Across different avatars, vary ethnicity, skin tone, and age — do not default every subject to the same ' \ 'middle-aged white presentation.', 'Vary clothing and styling — avoid repeating the same outfit archetype across different people.', 'Do not default homeowners (or anyone) to plaid, flannel, buffalo check, or rustic ' \ '"DIY weekend" stereotypes unless the setting clearly calls for cold-weather outdoor or trade workwear.', 'Prefer contemporary everyday clothes: solid colors or subtle textures; casual or smart-casual; not costume-like.', age_line, heritage_line, wardrobe_line, 'Choose a background that fits their profession or lifestyle rather than a generic blank interior.', 'Be creative: a trade professional might be in a workshop or on a job site; a homeowner might be in a garden, ' \ 'on a porch, in a sunlit kitchen, at a café patio, walking a neighborhood street, or a calm modern living space — ' \ 'not the same cosy living-room cliché every time.', 'Photography style, soft natural lighting, high detail. No text, no watermarks.' ].compact.join(' ') end |
.batch_stats_by_sku(skus) ⇒ Hash
Get review stats per SKU in a single query (batch method)
356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 |
# File 'app/models/reviews_io.rb', line 356 def self.batch_stats_by_sku(skus) return {} if skus.blank? # Single GROUP BY query to get per-SKU stats results = active .visible .product_reviews .by_skus(skus) .group(:sku) .pluck(:sku, Arel.sql('AVG(rating)'), Arel.sql('COUNT(*)')) # Convert to hash keyed by SKU results.each_with_object({}) do |(sku, avg, count), hash| hash[sku] = { star_avg: avg&.round(2), num: count.to_i } end end |
.by_rating ⇒ ActiveRecord::Relation<ReviewsIo>
A relation of ReviewsIos that are by rating. Active Record Scope
140 |
# File 'app/models/reviews_io.rb', line 140 scope :by_rating, ->() { where(rating: ) } |
.by_sku ⇒ ActiveRecord::Relation<ReviewsIo>
A relation of ReviewsIos that are by sku. Active Record Scope
138 |
# File 'app/models/reviews_io.rb', line 138 scope :by_sku, ->(sku) { where(sku: sku) } |
.by_skus ⇒ ActiveRecord::Relation<ReviewsIo>
A relation of ReviewsIos that are by skus. Active Record Scope
139 |
# File 'app/models/reviews_io.rb', line 139 scope :by_skus, ->(skus) { where(sku: skus) } |
.by_tag ⇒ ActiveRecord::Relation<ReviewsIo>
A relation of ReviewsIos that are by tag. Active Record Scope
146 |
# File 'app/models/reviews_io.rb', line 146 scope :by_tag, ->(tag) { where('tags @> ?', [tag].to_json) if tag.present? } |
.company_wide_stats ⇒ Hash
Company-wide stats across all visible reviews (store + product).
Cached 7 days — used for trust badges on landing page heroes and homepage benefits.
328 329 330 331 332 333 334 335 336 337 338 |
# File 'app/models/reviews_io.rb', line 328 def self.company_wide_stats Rails.cache.fetch('reviews_io/company_wide_stats/v2', expires_in: 7.days) do scope = active.visible total = scope.count { star_avg: scope.average(:rating)&.round(2), num: total, satisfaction_pct: ((scope.where('rating >= 4').count * 100.0 / total).round(1) if total.positive?) } end end |
.configured? ⇒ Boolean
Check if Reviews.io is configured and available
836 837 838 |
# File 'app/models/reviews_io.rb', line 836 def configured? store_id.present? && api_key.present? end |
.deleted ⇒ ActiveRecord::Relation<ReviewsIo>
A relation of ReviewsIos that are deleted. Active Record Scope
135 |
# File 'app/models/reviews_io.rb', line 135 scope :deleted, -> { where(status: STATUS_DELETED) } |
.embeddable ⇒ ActiveRecord::Relation<ReviewsIo>
A relation of ReviewsIos that are embeddable. Active Record Scope
134 |
# File 'app/models/reviews_io.rb', line 134 scope :embeddable, -> { active.visible.product_reviews.with_comments.high_rated } |
.embeddable_content_types ⇒ Object
Content types that can be embedded
438 439 440 |
# File 'app/models/reviews_io.rb', line 438 def self. [:primary] end |
.exclude_tags ⇒ ActiveRecord::Relation<ReviewsIo>
A relation of ReviewsIos that are exclude tags. Active Record Scope
153 154 155 156 157 158 |
# File 'app/models/reviews_io.rb', line 153 scope :exclude_tags, ->(*tag_names) { = [tag_names].flatten.filter_map(&:presence) return all if .empty? where.not("tags ?| ARRAY[#{.map { |t| connection.quote(t) }.join(', ')}]") } |
.fetch_reviews(sku:, page: 1, limit: 5, order: 'date_desc', include_item_name: false) ⇒ Hash
Fetch reviews for a specific SKU from the local database
693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 |
# File 'app/models/reviews_io.rb', line 693 def fetch_reviews(sku:, page: 1, limit: 5, order: 'date_desc', include_item_name: false) return empty_result(sku) if sku.blank? # Query from database (respect CRM / sync hiding) scope = active.visible.product_reviews.by_sku(sku) # Apply ordering scope = case order when 'rating_desc' then scope.order(rating: :desc, review_date: :desc) when 'rating_asc' then scope.order(rating: :asc, review_date: :desc) when 'date_asc' then scope.order(review_date: :asc) else scope.order(review_date: :desc) end # Get stats before pagination stats = stats_for_skus([sku]) # Apply pagination offset = (page - 1) * limit reviews = scope.limit(limit).offset(offset).to_a # Only look up item name if explicitly requested (to avoid N+1 in loops) item_name = include_item_name ? Item.find_by(sku: sku)&.name : nil { reviews: reviews, star_avg: stats[:star_avg], num: stats[:num], name: item_name, item_sku: sku, updated_at: reviews.first&.updated_at || Time.current } end |
.fetch_reviews_by_tag(tag:, page: 1, limit: 5, order: 'date_desc') ⇒ Hash
Fetch reviews by tag from the local database
793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 |
# File 'app/models/reviews_io.rb', line 793 def fetch_reviews_by_tag(tag:, page: 1, limit: 5, order: 'date_desc') return empty_result(nil) if tag.blank? # Query from database by tag (respect CRM / sync hiding) scope = active.visible.by_tag(tag) # Apply ordering scope = case order when 'rating_desc' then scope.order(rating: :desc, review_date: :desc) when 'rating_asc' then scope.order(rating: :asc, review_date: :desc) when 'date_asc' then scope.order(review_date: :asc) else scope.order(review_date: :desc) end # Get stats total_count = scope.count = scope.average(:rating)&.round(2) # Apply pagination offset = (page - 1) * limit reviews = scope.limit(limit).offset(offset).to_a { reviews: reviews, star_avg: , num: total_count, name: nil, item_sku: nil, updated_at: reviews.first&.updated_at || Time.current } end |
.fetch_reviews_for_item(item, page: 1, limit: 5, order: 'date_desc') ⇒ Hash
Fetch reviews for an Item instance
734 735 736 737 738 739 740 741 742 743 |
# File 'app/models/reviews_io.rb', line 734 def fetch_reviews_for_item(item, page: 1, limit: 5, order: 'date_desc') return empty_result(nil) if item&.sku.blank? fetch_reviews( sku: item.sku, page: page, limit: limit, order: order ) end |
.fetch_reviews_for_skus(skus, page: 1, limit: 5, order: 'date_desc') ⇒ Hash
Fetch reviews for multiple SKUs (useful for product line pages)
752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 |
# File 'app/models/reviews_io.rb', line 752 def fetch_reviews_for_skus(skus, page: 1, limit: 5, order: 'date_desc') return empty_result(nil) if skus.blank? skus_array = Array(skus).compact return empty_result(nil) if skus_array.empty? # Query from database for all SKUs (respect CRM / sync hiding) scope = active.visible.product_reviews.by_skus(skus_array) # Apply ordering scope = case order when 'rating_desc' then scope.order(rating: :desc, review_date: :desc) when 'rating_asc' then scope.order(rating: :asc, review_date: :desc) when 'date_asc' then scope.order(review_date: :asc) else scope.order(review_date: :desc) end # Get stats before pagination stats = stats_for_skus(skus_array) # Apply pagination offset = (page - 1) * limit reviews = scope.limit(limit).offset(offset).to_a { reviews: reviews, star_avg: stats[:star_avg], num: stats[:num], name: nil, item_sku: skus_array.first, updated_at: reviews.first&.updated_at || Time.current } end |
.hidden_reviews ⇒ ActiveRecord::Relation<ReviewsIo>
A relation of ReviewsIos that are hidden reviews. Active Record Scope
133 |
# File 'app/models/reviews_io.rb', line 133 scope :hidden_reviews, -> { where(hidden: true) } |
.high_rated ⇒ ActiveRecord::Relation<ReviewsIo>
A relation of ReviewsIos that are high rated. Active Record Scope
141 |
# File 'app/models/reviews_io.rb', line 141 scope :high_rated, -> { where(rating: 4..) } |
.mark_stale_as_deleted(import_time) ⇒ Integer
Mark reviews not seen in this import as deleted
674 675 676 677 678 |
# File 'app/models/reviews_io.rb', line 674 def mark_stale_as_deleted(import_time) active .where('last_seen_at < ? OR last_seen_at IS NULL', import_time) .update_all(status: STATUS_DELETED, deleted_at: Time.current) end |
.merge_tags_with_crm(existing_tags, api_tags) ⇒ Array<String>
Merge API-sourced tags with CRM-managed tags already on the record.
CRM tags (prefixed "for-") are preserved; API tags are fully replaced.
630 631 632 633 |
# File 'app/models/reviews_io.rb', line 630 def (, ) = Array().select { |t| t.start_with?(CRM_TAG_PREFIX) } ( + Array()).uniq end |
.needs_sync ⇒ ActiveRecord::Relation<ReviewsIo>
A relation of ReviewsIos that are needs sync. Active Record Scope
143 |
# File 'app/models/reviews_io.rb', line 143 scope :needs_sync, -> { where(last_seen_at: ...25.hours.ago) } |
.product_reviews ⇒ ActiveRecord::Relation<ReviewsIo>
A relation of ReviewsIos that are product reviews. Active Record Scope
136 |
# File 'app/models/reviews_io.rb', line 136 scope :product_reviews, -> { where(review_type: 'product_review') } |
.ransackable_attributes(_auth_object = nil) ⇒ Object
Ransack configuration
171 172 173 |
# File 'app/models/reviews_io.rb', line 171 def self.ransackable_attributes(_auth_object = nil) %w[author_name comments hidden id order_id rating review_date review_type sku status title verified_buyer] end |
.ransackable_scopes(_auth_object = nil) ⇒ Object
175 176 177 |
# File 'app/models/reviews_io.rb', line 175 def self.ransackable_scopes(_auth_object = nil) %i[by_tag tags_include exclude_tags with_photos with_imported_images] end |
.rating_snapshot(sku) ⇒ Hash
Get rating snapshot for a SKU (average rating and count only)
829 830 831 |
# File 'app/models/reviews_io.rb', line 829 def (sku) ::Reviews::RatingSnapshot.for_sku(sku) end |
.recent ⇒ ActiveRecord::Relation<ReviewsIo>
A relation of ReviewsIos that are recent. Active Record Scope
142 |
# File 'app/models/reviews_io.rb', line 142 scope :recent, -> { order(review_date: :desc) } |
.reviews_for_product_line(product_line, page: 1, per_page: nil, min_length: nil) ⇒ Hash
Get full review data for a product line (compatible with Rating class)
300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 |
# File 'app/models/reviews_io.rb', line 300 def self.reviews_for_product_line(product_line, page: 1, per_page: nil, min_length: nil) skus = skus_for_product_line(product_line) return { star_avg: nil, num: 0, reviews: [], updated_at: Time.current } if skus.blank? base_scope = active.visible.product_reviews.by_skus(skus) stats_scope = base_scope # Apply min_length filter for fetching reviews reviews_scope = base_scope.recent reviews_scope = reviews_scope.where('LENGTH(comments) >= ?', min_length) if min_length.present? # Apply pagination if per_page.present? offset = (page - 1) * per_page reviews_scope = reviews_scope.limit(per_page).offset(offset) end { star_avg: stats_scope.average(:rating)&.round(2), num: stats_scope.count, reviews: reviews_scope.to_a, updated_at: base_scope.maximum(:updated_at) || Time.current } end |
.skus_for_product_line(product_line) ⇒ Array<String>
Get SKUs for a product line
376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 |
# File 'app/models/reviews_io.rb', line 376 def self.skus_for_product_line(product_line) pl = case product_line when ProductLine then product_line when Integer then ProductLine.find_by(id: product_line) when String then ProductLine.find_by(slug_ltree: LtreePaths.slug_ltree_from_legacy_hyphen_url(product_line)) || ProductLine.find_by(slug_ltree: product_line) || ProductLine.find_by(id: product_line) end return [] unless pl # Use ltree path directly on items (no separate ProductLine query needed) # This uses the GiST-indexed primary_pl_path_ids column on items if pl.ltree_path_ids.present? Item.where(Item[:primary_pl_path_ids].ltree_descendant(pl.ltree_path_ids)) .pluck(:sku) .compact_blank else # Fallback for product lines without ltree paths Item.where(primary_product_line_id: ProductLine.self_and_descendants_ids(pl.id)) .pluck(:sku) .compact_blank end end |
.stats_for_product_line(product_line) ⇒ Hash
Get review stats (average rating and count) for a product line
212 213 214 215 216 217 218 219 220 221 |
# File 'app/models/reviews_io.rb', line 212 def self.stats_for_product_line(product_line) skus = skus_for_product_line(product_line) return { star_avg: nil, num: 0 } if skus.blank? scope = active.visible.product_reviews.by_skus(skus) { star_avg: scope.average(:rating)&.round(2), num: scope.count } end |
.stats_for_product_lines(product_lines) ⇒ Hash
Batch method: Get review stats for multiple product lines in fewer queries
226 227 228 229 230 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 279 280 281 282 283 284 285 286 287 288 289 290 291 292 |
# File 'app/models/reviews_io.rb', line 226 def self.stats_for_product_lines(product_lines) pls = Array(product_lines).compact return {} if pls.empty? # Get all ltree paths paths_by_id = pls.each_with_object({}) do |pl, hash| hash[pl.id] = pl.ltree_path_ids if pl.ltree_path_ids.present? end return pls.index_with { { star_avg: nil, num: 0 } } if paths_by_id.empty? # Single query to get all SKUs with their ltree paths items_data = Item.where(Item[:primary_pl_path_ids].ltree_descendant(paths_by_id.values)) .pluck(:sku, :primary_pl_path_ids) # Group SKUs by product line (item belongs to PL if item's path descends from PL's path) skus_by_pl = pls.index_with { [] } items_data.each do |sku, item_path| next if sku.blank? || item_path.blank? item_path_str = item_path.to_s pls.each do |pl| pl_path = paths_by_id[pl.id] next if pl_path.blank? # Check if item is descendant of this PL (path starts with PL path) pl_path_str = pl_path.to_s skus_by_pl[pl] << sku if item_path_str == pl_path_str || item_path_str.start_with?("#{pl_path_str}.") end end # Now compute stats for each PL's SKUs in bulk all_skus = skus_by_pl.values.flatten.uniq return pls.index_with { { star_avg: nil, num: 0 } } if all_skus.empty? # Get all reviews for all SKUs at once reviews_by_sku = active.visible.product_reviews.by_skus(all_skus) .group(:sku) .pluck(:sku, Arel.sql('AVG(rating)'), Arel.sql('COUNT(*)')) .index_by(&:first) # Compute stats per PL pls.each_with_object({}) do |pl, result| pl_skus = skus_by_pl[pl] if pl_skus.blank? result[pl.id] = { star_avg: nil, num: 0 } next end = 0.0 total_count = 0 pl_skus.each do |sku| review_data = reviews_by_sku[sku] next unless review_data avg = review_data[1]&.to_f || 0 count = review_data[2].to_i += avg * count total_count += count end result[pl.id] = { star_avg: total_count.positive? ? ( / total_count).round(2) : nil, num: total_count } end end |
.stats_for_skus(skus) ⇒ Hash
Get review stats for specific SKUs (aggregated)
343 344 345 346 347 348 349 350 351 |
# File 'app/models/reviews_io.rb', line 343 def self.stats_for_skus(skus) return { star_avg: nil, num: 0 } if skus.blank? scope = active.visible.product_reviews.by_skus(skus) { star_avg: scope.average(:rating)&.round(2), num: scope.count } end |
.store_review_stats ⇒ Hash
Get store review stats (average rating and count)
201 202 203 204 205 206 207 |
# File 'app/models/reviews_io.rb', line 201 def self.store_review_stats scope = active.visible.store_reviews { star_avg: scope.average(:rating)&.round(2), num: scope.count } end |
.store_reviews ⇒ ActiveRecord::Relation<ReviewsIo>
A relation of ReviewsIos that are store reviews. Active Record Scope
137 |
# File 'app/models/reviews_io.rb', line 137 scope :store_reviews, -> { where(review_type: 'store_review') } |
.tags_include ⇒ ActiveRecord::Relation<ReviewsIo>
A relation of ReviewsIos that are tags include. Active Record Scope
147 148 149 150 151 152 |
# File 'app/models/reviews_io.rb', line 147 scope :tags_include, ->(*tag_names) { = [tag_names].flatten.filter_map(&:presence) return all if .empty? where("tags ?| ARRAY[#{.map { |t| connection.quote(t) }.join(', ')}]") } |
.upsert_from_api(review_data, import_time: Time.current) ⇒ ReviewsIo
Upsert a review from Reviews.io API response
640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 |
# File 'app/models/reviews_io.rb', line 640 def upsert_from_api(review_data, import_time: Time.current) reviews_io_id = review_data['id']&.to_s return nil if reviews_io_id.blank? record = find_or_initialize_by(reviews_io_id: reviews_io_id) record.assign_attributes( review_type: review_data['type'], sku: review_data['sku'], rating: review_data['rating'], title: review_data['title'], comments: review_data['comments'], author_name: (review_data), author_location: (review_data), author_email: (review_data), review_date: parse_review_date(review_data['date_created']), order_id: review_data['order_id'], photos: review_data['photos'] || [], videos: review_data['videos'] || [], review_attributes: review_data['attributes'] || {}, tags: (record., review_data['tags'] || []), replies: review_data['replies'] || [], hidden: Api::ReviewsIo::ReviewPayload.hidden_after_sync?(review_data, record), status: STATUS_ACTIVE, last_seen_at: import_time, deleted_at: nil ) record.save! record end |
.verified ⇒ ActiveRecord::Relation<ReviewsIo>
A relation of ReviewsIos that are verified. Active Record Scope
145 |
# File 'app/models/reviews_io.rb', line 145 scope :verified, -> { where(verified_buyer: true) } |
.visible ⇒ ActiveRecord::Relation<ReviewsIo>
A relation of ReviewsIos that are visible. Active Record Scope
132 |
# File 'app/models/reviews_io.rb', line 132 scope :visible, -> { where(hidden: false) } |
.with_comments ⇒ ActiveRecord::Relation<ReviewsIo>
A relation of ReviewsIos that are with comments. Active Record Scope
144 |
# File 'app/models/reviews_io.rb', line 144 scope :with_comments, -> { where.not(comments: [nil, '']) } |
.with_imported_images ⇒ ActiveRecord::Relation<ReviewsIo>
A relation of ReviewsIos that are with imported images. Active Record Scope
162 |
# File 'app/models/reviews_io.rb', line 162 scope :with_imported_images, ->(_value = true) { joins(:reviews_io_images).distinct } |
.with_imported_videos ⇒ ActiveRecord::Relation<ReviewsIo>
A relation of ReviewsIos that are with imported videos. Active Record Scope
167 |
# File 'app/models/reviews_io.rb', line 167 scope :with_imported_videos, -> { joins(:reviews_io_videos).distinct } |
.with_photos ⇒ ActiveRecord::Relation<ReviewsIo>
A relation of ReviewsIos that are with photos. Active Record Scope
159 160 161 |
# File 'app/models/reviews_io.rb', line 159 scope :with_photos, ->(_value = true) { where("jsonb_array_length(COALESCE(photos, '[]'::jsonb)) > 0 OR jsonb_array_length(COALESCE(raw_api_data->'photos_raw', '[]'::jsonb)) > 0") } |
.with_videos ⇒ ActiveRecord::Relation<ReviewsIo>
A relation of ReviewsIos that are with videos. Active Record Scope
164 165 166 |
# File 'app/models/reviews_io.rb', line 164 scope :with_videos, -> { where("jsonb_array_length(COALESCE(videos, '[]'::jsonb)) > 0 OR jsonb_array_length(COALESCE(raw_api_data->'videos', '[]'::jsonb)) > 0") } |
.without_imported_images ⇒ ActiveRecord::Relation<ReviewsIo>
A relation of ReviewsIos that are without imported images. Active Record Scope
163 |
# File 'app/models/reviews_io.rb', line 163 scope :without_imported_images, -> { where.not(id: ReviewsIoImage.select(:reviews_io_id)) } |
.without_imported_videos ⇒ ActiveRecord::Relation<ReviewsIo>
A relation of ReviewsIos that are without imported videos. Active Record Scope
168 |
# File 'app/models/reviews_io.rb', line 168 scope :without_imported_videos, -> { where.not(id: ReviewsIoVideo.select(:reviews_io_id)) } |
Instance Method Details
#active? ⇒ Boolean
Instance methods
403 404 405 |
# File 'app/models/reviews_io.rb', line 403 def active? status == STATUS_ACTIVE end |
#all_photos_imported? ⇒ Boolean
Callers iterating over multiple records should preload associations:
ReviewsIo.includes(:reviews_io_images, :reviews_io_videos)
565 566 567 568 569 |
# File 'app/models/reviews_io.rb', line 565 def all_photos_imported? return true unless has_photos? reviews_io_images.loaded? ? reviews_io_images.length >= photo_urls.size : reviews_io_images.size >= photo_urls.size end |
#all_videos_imported? ⇒ Boolean
571 572 573 574 575 |
# File 'app/models/reviews_io.rb', line 571 def all_videos_imported? return true unless has_videos? reviews_io_videos.loaded? ? reviews_io_videos.length >= video_urls.size : reviews_io_videos.size >= video_urls.size end |
#avatar_image ⇒ Image
79 |
# File 'app/models/reviews_io.rb', line 79 belongs_to :avatar_image, class_name: 'Image', optional: true |
#content_for_embedding(_content_type = :primary) ⇒ Object
Generate content for semantic search embedding
Includes the review text plus product context
444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 |
# File 'app/models/reviews_io.rb', line 444 def (_content_type = :primary) parts = [] # Review content parts << "Review Title: #{title}" if title.present? parts << "Review: #{comments}" if comments.present? # Rating context parts << "Rating: #{} out of 5 stars" if .present? parts << 'Verified Buyer' if verified_buyer? # Product context from SKU if sku.present? item = Item.find_by(sku: sku) if item parts << "Product: #{item.name}" parts << "Product Line: #{item.primary_product_line.}" if item.primary_product_line end end # Location context (where the product was installed) parts << "Location: #{}" if .present? # Tags (often contain useful categorization) parts << "Tags: #{.join(', ')}" if .present? parts.compact.join("\n\n") end |
#deleted? ⇒ Boolean
407 408 409 |
# File 'app/models/reviews_io.rb', line 407 def deleted? status == STATUS_DELETED end |
#embedding_content_changed? ⇒ Boolean
Detect if content changed (for automatic re-embedding)
474 475 476 |
# File 'app/models/reviews_io.rb', line 474 def saved_change_to_title? || saved_change_to_comments? || end |
#full_review ⇒ Object
Alias comments to full_review for view compatibility
489 490 491 |
# File 'app/models/reviews_io.rb', line 489 def full_review comments end |
#has_media? ⇒ Boolean
Returns true if the review has any media (photos or videos).
559 560 561 |
# File 'app/models/reviews_io.rb', line 559 def has_media? has_photos? || has_videos? end |
#has_photos? ⇒ Boolean
Returns true if the review has any photos — checks both the photos column
and photos_raw in raw_api_data (the API sometimes populates one but not the other).
527 528 529 530 |
# File 'app/models/reviews_io.rb', line 527 def has_photos? (photos.is_a?(Array) && photos.any?) || raw_api_data_photos_raw.any? end |
#has_videos? ⇒ Boolean
Returns true if the review has any video assets.
541 542 543 544 |
# File 'app/models/reviews_io.rb', line 541 def has_videos? (videos.is_a?(Array) && videos.any?) || raw_api_data_videos.any? end |
#headline_review ⇒ Object
Returns the headline used on review cards and in schema.org markup.
Staff-set custom_headline takes priority; falls back to the Reviews.io title.
The sync never writes custom_headline, so it is safe from being overridden.
484 485 486 |
# File 'app/models/reviews_io.rb', line 484 def headline_review custom_headline.presence || title end |
#images ⇒ ActiveRecord::Relation<Image>
76 |
# File 'app/models/reviews_io.rb', line 76 has_many :images, through: :reviews_io_images |
#imported_videos ⇒ ActiveRecord::Relation<ImportedVideo>
78 |
# File 'app/models/reviews_io.rb', line 78 has_many :imported_videos, through: :reviews_io_videos, source: :video |
#mark_as_deleted! ⇒ Object
577 578 579 |
# File 'app/models/reviews_io.rb', line 577 def mark_as_deleted! update!(status: STATUS_DELETED, deleted_at: Time.current) end |
#mark_as_seen! ⇒ Object
581 582 583 |
# File 'app/models/reviews_io.rb', line 581 def mark_as_seen! update!(last_seen_at: Time.current, status: STATUS_ACTIVE, deleted_at: nil) end |
#obfuscated_reviewer_name ⇒ Object
Return obfuscated name for privacy (matches ReviewProxy behavior)
514 515 516 517 518 |
# File 'app/models/reviews_io.rb', line 514 def return nil if .blank? PersonNameParser.new(). end |
#photo_urls ⇒ Object
Extracts photo URLs from photos column first, falling back to photos_raw in raw_api_data.
Returns the original S3 URLs whenever possible (photos_raw) rather than resized thumbnails.
534 535 536 537 538 |
# File 'app/models/reviews_io.rb', line 534 def photo_urls urls = extract_urls_from_photos urls = extract_urls_from_photos_raw if urls.empty? urls end |
#reviewer_city ⇒ Object
Extract city from author_location (format: "City, State" or "City, Country")
499 500 501 502 503 |
# File 'app/models/reviews_io.rb', line 499 def reviewer_city return nil if .blank? .split(',').first&.strip end |
#reviewer_name ⇒ Object
Accessor for reviewer_name (alias of author_name)
521 522 523 |
# File 'app/models/reviews_io.rb', line 521 def reviewer_name end |
#reviewer_state ⇒ Object
Extract state from author_location
506 507 508 509 510 511 |
# File 'app/models/reviews_io.rb', line 506 def reviewer_state return nil if .blank? parts = .split(',') parts[1]&.strip if parts.size > 1 end |
#reviews_io_images ⇒ ActiveRecord::Relation<ReviewsIoImage>
Associations
75 |
# File 'app/models/reviews_io.rb', line 75 has_many :reviews_io_images, dependent: :destroy |
#reviews_io_videos ⇒ ActiveRecord::Relation<ReviewsIoVideo>
77 |
# File 'app/models/reviews_io.rb', line 77 has_many :reviews_io_videos, dependent: :destroy |
#star_rating_level ⇒ Object
Alias rating to star_rating_level for view compatibility
494 495 496 |
# File 'app/models/reviews_io.rb', line 494 def end |
#video_urls ⇒ Object
Extracts video URLs from the videos column or raw_api_data.
547 548 549 550 551 552 553 554 555 556 |
# File 'app/models/reviews_io.rb', line 547 def video_urls vids = Array(videos).filter_map do |v| v.is_a?(Hash) ? (v['video'] || v['url']) : v.presence end return vids if vids.any? raw_api_data_videos.filter_map do |v| v.is_a?(Hash) ? (v['video'] || v['url']) : v.presence end end |