From e4342da44af74e003007376835b570a3177d13dc Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Thu, 27 Aug 2026 11:32:20 +0300 Subject: [PATCH] Fix: Correcting shape of indexed products --- docs/product-listing.md | 16 ++++++++-------- src/Product/Services/ProductIndexer.php | 24 ++++++++++++++---------- src/Product/Services/ProductService.php | 2 +- 3 files changed, 23 insertions(+), 19 deletions(-) diff --git a/docs/product-listing.md b/docs/product-listing.md index 8a331ed..2da31b3 100644 --- a/docs/product-listing.md +++ b/docs/product-listing.md @@ -72,15 +72,14 @@ needs, listing and detail alike: | Field | Source | Notes | |---|---|---| | `id` | — | Newly marked **filterable** — needed for `getById()`'s `id = "..."` filter; Meilisearch doesn't filter on the primary key by default. | -| `collections` | `$product->collections->pluck('id')` | Filterable. Array of collection IDs (as strings) — filtering matches by ID, not slug. | -| `collection_names` | `$product->collections` | Display only, not filterable — translated collection names. | +| `collections` | `$product->collections` | Array of `{id, name}` — `name` is the translated collection name. Filterable on the nested field `collections.id`, not `collections` itself. | | `slugs` | `$product->urls->pluck('slug')` | Filterable. Every locale's `Url::slug` for the product, so `getBySlug()` resolves purely from the index — no database read. | | `price` | Cheapest variant's base price | Filterable. Float in major units (e.g. `19.99`, not `1999`). Base price only — no customer group, default currency (`Currency::getDefault()`) only. `null` if the product has no priced variant yet, so it's excluded from range filters rather than treated as free. | | `brand` | Already indexed by Lunar's base indexer | Newly marked **filterable** — it existed in the document already, just wasn't usable in a `filter` clause. | | `tags` | `$product->tags->pluck('value')` | Display only. | | `media` | `$product->media` | Full gallery (id/url/thumb per image), not just the single thumbnail Lunar's base indexer sends. | | `variants` | `$product->variants` | Per variant: `id`, `sku`, `stock`, `purchasable`, `options` (option/value names, in the current locale), `prices` (per currency/customer group), `media` (variant-specific images). | -| `reviews`, `review_count`, `average_rating` | `Modules\Core\Review\Models\ProductReview` | See "Reviews" below. | +| `reviews` | `Modules\Core\Review\Models\ProductReview` | `{items, count, average_rating}` — see "Reviews" below. | `name`/`description` (and any other `TranslatedText` attribute) are indexed per-locale — see "Locale resolution" below for how `ProductService` resolves them down to one value per request. @@ -121,11 +120,12 @@ description sourced from `ProductService`'s results must treat it as trusted HTM ## Reviews -`Modules\Core\Review\Models\ProductReview` (`product_reviews` table) is indexed per-product as -`reviews` (array), plus `review_count` and `average_rating` (rounded to 1 decimal, `null` if the -product has no reviews). Only public-safe fields are included — **`reviewer_email` is deliberately -excluded**, it's PII with no storefront use. `reply`/`replied_at` (the staff response) are -included, since they're meant to be shown alongside the review. +`Modules\Core\Review\Models\ProductReview` (`product_reviews` table) is indexed per-product under +a single `reviews` key: `{items, count, average_rating}` — `items` is the array of reviews, +`average_rating` is rounded to 1 decimal (`null` if the product has no reviews). Only public-safe +fields are included on each item — **`reviewer_email` is deliberately excluded**, it's PII with no +storefront use. `reply`/`replied_at` (the staff response) are included, since they're meant to be +shown alongside the review. A review is created/edited independently of its product (a customer submission, a staff reply) — its own save doesn't touch the `Product` row, so the product's own model events never fire. diff --git a/src/Product/Services/ProductIndexer.php b/src/Product/Services/ProductIndexer.php index 68a15f7..7bb019c 100644 --- a/src/Product/Services/ProductIndexer.php +++ b/src/Product/Services/ProductIndexer.php @@ -16,7 +16,7 @@ use Spatie\MediaLibrary\MediaCollections\Models\Media; * Extends Lunar's own indexer so Modules\Core\Product\Services\ProductService can * serve both listing/filtering AND single-product lookups from Meilisearch alone — * one data source, no separate database read path for a product detail page. Adds: - * - collections (ids, filterable) and collection_names (display) + * - collections: [{id, name}, ...] — filterable via `collections.id` * - slugs (every locale's Url::slug for the product, filterable) — lets * ProductService::getBySlug() resolve a product from the index directly, with * no database read at all @@ -24,9 +24,9 @@ use Spatie\MediaLibrary\MediaCollections\Models\Media; * - variants: sku, stock, purchasable, option values, prices, media * - the full media gallery (not just the single thumbnail Lunar's base indexer sends) * - tags - * - reviews: public-safe fields only (see mapReview() — reviewer_email is deliberately - * excluded, it's PII with no storefront use), including staff replies, plus an - * average rating + * - reviews: {items: [...], count, average_rating} — items are public-safe fields + * only (see mapReview() — reviewer_email is deliberately excluded, it's PII with + * no storefront use), including staff replies * - channel_ids (filterable) — Lunar's base indexer only indexes "status" as * filterable, not channel assignment, so search results can't otherwise be * scoped to products actually assigned+enabled on the current sales channel @@ -49,7 +49,7 @@ class ProductIndexer extends BaseProductIndexer ...parent::getFilterableFields(), 'id', 'brand', - 'collections', + 'collections.id', 'price', 'slugs', 'channel_ids', @@ -85,16 +85,20 @@ class ProductIndexer extends BaseProductIndexer $currency = Currency::getDefault(); $reviews = ProductReview::where('product_id', $model->id)->with('media')->get(); - $data['collections'] = $model->collections->pluck('id')->map(fn ($id) => (string) $id)->all(); - $data['collection_names'] = $model->collections->map(fn ($collection) => $collection->translateAttribute('name'))->all(); + $data['collections'] = $model->collections->map(fn ($collection) => [ + 'id' => $collection->id, + 'name' => $collection->translateAttribute('name'), + ])->all(); $data['slugs'] = $model->urls->pluck('slug')->unique()->values()->all(); $data['tags'] = $model->tags->pluck('value')->all(); $data['media'] = $model->media->map(fn (Media $media) => $this->mapMedia($media))->all(); $data['variants'] = $model->variants->map(fn (ProductVariant $variant) => $this->mapVariant($variant, $currency))->all(); $data['price'] = $this->cheapestPrice($model, $currency); - $data['reviews'] = $reviews->map(fn (ProductReview $review) => $this->mapReview($review))->all(); - $data['review_count'] = $reviews->count(); - $data['average_rating'] = $reviews->isEmpty() ? null : round($reviews->avg('rating'), 1); + $data['reviews'] = [ + 'items' => $reviews->map(fn (ProductReview $review) => $this->mapReview($review))->all(), + 'count' => $reviews->count(), + 'average_rating' => $reviews->isEmpty() ? null : round($reviews->avg('rating'), 1), + ]; $data['channel_ids'] = $model->channels() ->wherePivot('enabled', true) ->pluck('lunar_channels.id') diff --git a/src/Product/Services/ProductService.php b/src/Product/Services/ProductService.php index 5e1d8e1..dcb4440 100644 --- a/src/Product/Services/ProductService.php +++ b/src/Product/Services/ProductService.php @@ -157,7 +157,7 @@ class ProductService } $clauses = Collection::make([ - $filters->collectionId !== null ? "collections = \"{$filters->collectionId}\"" : null, + $filters->collectionId !== null ? "collections.id = \"{$filters->collectionId}\"" : null, $filters->brand !== null ? 'brand = "'.addcslashes($filters->brand, '"\\').'"' : null, $filters->minPrice !== null ? "price >= {$filters->minPrice}" : null, $filters->maxPrice !== null ? "price <= {$filters->maxPrice}" : null,