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 =
'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::DEFAULT_MODEL, Models::Embeddable::MAX_CONTENT_LENGTH
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 Models::EventPublishable
Instance Attribute Details
#reviews_io_id ⇒ Object (readonly)
Validations
Validations:
400 |
# File 'app/models/reviews_io.rb', line 400 validates :reviews_io_id, presence: true, uniqueness: true |
#status ⇒ Object (readonly)
401 |
# File 'app/models/reviews_io.rb', line 401 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
130 |
# File 'app/models/reviews_io.rb', line 130 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.
184 185 186 187 188 189 190 191 192 193 194 195 196 |
# File 'app/models/reviews_io.rb', line 184 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.
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 621 622 623 |
# File 'app/models/reviews_io.rb', line 590 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)
357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 |
# File 'app/models/reviews_io.rb', line 357 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
139 |
# File 'app/models/reviews_io.rb', line 139 scope :by_rating, ->() { where(rating: ) } |
.by_sku ⇒ ActiveRecord::Relation<ReviewsIo>
A relation of ReviewsIos that are by sku. Active Record Scope
137 |
# File 'app/models/reviews_io.rb', line 137 scope :by_sku, ->(sku) { where(sku: sku) } |
.by_skus ⇒ ActiveRecord::Relation<ReviewsIo>
A relation of ReviewsIos that are by skus. Active Record Scope
138 |
# File 'app/models/reviews_io.rb', line 138 scope :by_skus, ->(skus) { where(sku: skus) } |
.by_tag ⇒ ActiveRecord::Relation<ReviewsIo>
A relation of ReviewsIos that are by tag. Active Record Scope
145 |
# File 'app/models/reviews_io.rb', line 145 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.
327 328 329 330 331 332 333 334 335 336 337 338 339 |
# File 'app/models/reviews_io.rb', line 327 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: if total.positive? (scope.where('rating >= 4').count * 100.0 / total).round(1) end } end end |
.configured? ⇒ Boolean
Check if Reviews.io is configured and available
839 840 841 |
# File 'app/models/reviews_io.rb', line 839 def configured? store_id.present? && api_key.present? end |
.deleted ⇒ ActiveRecord::Relation<ReviewsIo>
A relation of ReviewsIos that are deleted. Active Record Scope
134 |
# File 'app/models/reviews_io.rb', line 134 scope :deleted, -> { where(status: STATUS_DELETED) } |
.embeddable ⇒ ActiveRecord::Relation<ReviewsIo>
A relation of ReviewsIos that are embeddable. Active Record Scope
133 |
# File 'app/models/reviews_io.rb', line 133 scope :embeddable, -> { active.visible.product_reviews.with_comments.high_rated } |
.embeddable_content_types ⇒ Object
Content types that can be embedded
439 440 441 |
# File 'app/models/reviews_io.rb', line 439 def self. [:primary] end |
.exclude_tags ⇒ ActiveRecord::Relation<ReviewsIo>
A relation of ReviewsIos that are exclude tags. Active Record Scope
152 153 154 155 156 157 |
# File 'app/models/reviews_io.rb', line 152 scope :exclude_tags, ->(*tag_names) { = [tag_names].flatten.map(&:presence).compact 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
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 726 727 728 |
# File 'app/models/reviews_io.rb', line 696 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
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 824 825 826 |
# File 'app/models/reviews_io.rb', line 796 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
737 738 739 740 741 742 743 744 745 746 |
# File 'app/models/reviews_io.rb', line 737 def fetch_reviews_for_item(item, page: 1, limit: 5, order: 'date_desc') return empty_result(nil) unless item&.sku.present? 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)
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 785 786 787 |
# File 'app/models/reviews_io.rb', line 755 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
132 |
# File 'app/models/reviews_io.rb', line 132 scope :hidden_reviews, -> { where(hidden: true) } |
.high_rated ⇒ ActiveRecord::Relation<ReviewsIo>
A relation of ReviewsIos that are high rated. Active Record Scope
140 |
# File 'app/models/reviews_io.rb', line 140 scope :high_rated, -> { where('rating >= ?', 4) } |
.mark_stale_as_deleted(import_time) ⇒ Integer
Mark reviews not seen in this import as deleted
677 678 679 680 681 |
# File 'app/models/reviews_io.rb', line 677 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.
633 634 635 636 |
# File 'app/models/reviews_io.rb', line 633 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
142 |
# File 'app/models/reviews_io.rb', line 142 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
135 |
# File 'app/models/reviews_io.rb', line 135 scope :product_reviews, -> { where(review_type: 'product_review') } |
.ransackable_attributes(_auth_object = nil) ⇒ Object
Ransack configuration
170 171 172 |
# File 'app/models/reviews_io.rb', line 170 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
174 175 176 |
# File 'app/models/reviews_io.rb', line 174 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)
832 833 834 |
# File 'app/models/reviews_io.rb', line 832 def (sku) ::Reviews::RatingSnapshot.for_sku(sku) end |
.recent ⇒ ActiveRecord::Relation<ReviewsIo>
A relation of ReviewsIos that are recent. Active Record Scope
141 |
# File 'app/models/reviews_io.rb', line 141 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)
299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 |
# File 'app/models/reviews_io.rb', line 299 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
377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 |
# File 'app/models/reviews_io.rb', line 377 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
211 212 213 214 215 216 217 218 219 220 |
# File 'app/models/reviews_io.rb', line 211 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
225 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 |
# File 'app/models/reviews_io.rb', line 225 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 unless pl_path.present? # 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)
344 345 346 347 348 349 350 351 352 |
# File 'app/models/reviews_io.rb', line 344 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)
200 201 202 203 204 205 206 |
# File 'app/models/reviews_io.rb', line 200 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
136 |
# File 'app/models/reviews_io.rb', line 136 scope :store_reviews, -> { where(review_type: 'store_review') } |
.tags_include ⇒ ActiveRecord::Relation<ReviewsIo>
A relation of ReviewsIos that are tags include. Active Record Scope
146 147 148 149 150 151 |
# File 'app/models/reviews_io.rb', line 146 scope :tags_include, ->(*tag_names) { = [tag_names].flatten.map(&:presence).compact 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
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 669 670 671 |
# File 'app/models/reviews_io.rb', line 643 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
144 |
# File 'app/models/reviews_io.rb', line 144 scope :verified, -> { where(verified_buyer: true) } |
.visible ⇒ ActiveRecord::Relation<ReviewsIo>
A relation of ReviewsIos that are visible. Active Record Scope
131 |
# File 'app/models/reviews_io.rb', line 131 scope :visible, -> { where(hidden: false) } |
.with_comments ⇒ ActiveRecord::Relation<ReviewsIo>
A relation of ReviewsIos that are with comments. Active Record Scope
143 |
# File 'app/models/reviews_io.rb', line 143 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
161 |
# File 'app/models/reviews_io.rb', line 161 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
166 |
# File 'app/models/reviews_io.rb', line 166 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
158 159 160 |
# File 'app/models/reviews_io.rb', line 158 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
163 164 165 |
# File 'app/models/reviews_io.rb', line 163 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
162 |
# File 'app/models/reviews_io.rb', line 162 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
167 |
# File 'app/models/reviews_io.rb', line 167 scope :without_imported_videos, -> { where.not(id: ReviewsIoVideo.select(:reviews_io_id)) } |
Instance Method Details
#active? ⇒ Boolean
Instance methods
404 405 406 |
# File 'app/models/reviews_io.rb', line 404 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)
568 569 570 571 572 |
# File 'app/models/reviews_io.rb', line 568 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
574 575 576 577 578 |
# File 'app/models/reviews_io.rb', line 574 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, foreign_key: :avatar_image_id |
#content_for_embedding(_content_type = :primary) ⇒ Object
Generate content for semantic search embedding
Includes the review text plus product context
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 472 473 474 |
# File 'app/models/reviews_io.rb', line 445 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}" if item.primary_product_line parts << "Product Line: #{item.primary_product_line.}" end 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
408 409 410 |
# File 'app/models/reviews_io.rb', line 408 def deleted? status == STATUS_DELETED end |
#embedding_content_changed? ⇒ Boolean
Detect if content changed (for automatic re-embedding)
477 478 479 |
# File 'app/models/reviews_io.rb', line 477 def saved_change_to_title? || saved_change_to_comments? || end |
#full_review ⇒ Object
Alias comments to full_review for view compatibility
492 493 494 |
# File 'app/models/reviews_io.rb', line 492 def full_review comments end |
#has_media? ⇒ Boolean
Returns true if the review has any media (photos or videos).
562 563 564 |
# File 'app/models/reviews_io.rb', line 562 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).
530 531 532 533 |
# File 'app/models/reviews_io.rb', line 530 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.
544 545 546 547 |
# File 'app/models/reviews_io.rb', line 544 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.
487 488 489 |
# File 'app/models/reviews_io.rb', line 487 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
580 581 582 |
# File 'app/models/reviews_io.rb', line 580 def mark_as_deleted! update!(status: STATUS_DELETED, deleted_at: Time.current) end |
#mark_as_seen! ⇒ Object
584 585 586 |
# File 'app/models/reviews_io.rb', line 584 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)
517 518 519 520 521 |
# File 'app/models/reviews_io.rb', line 517 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.
537 538 539 540 541 |
# File 'app/models/reviews_io.rb', line 537 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")
502 503 504 505 506 |
# File 'app/models/reviews_io.rb', line 502 def reviewer_city return nil if .blank? .split(',').first&.strip end |
#reviewer_name ⇒ Object
Accessor for reviewer_name (alias of author_name)
524 525 526 |
# File 'app/models/reviews_io.rb', line 524 def reviewer_name end |
#reviewer_state ⇒ Object
Extract state from author_location
509 510 511 512 513 514 |
# File 'app/models/reviews_io.rb', line 509 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
497 498 499 |
# File 'app/models/reviews_io.rb', line 497 def end |
#video_urls ⇒ Object
Extracts video URLs from the videos column or raw_api_data.
550 551 552 553 554 555 556 557 558 559 |
# File 'app/models/reviews_io.rb', line 550 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 |