Fix: Correcting shape of indexed products

This commit is contained in:
2026-08-27 11:32:20 +03:00
parent e95ea4a43c
commit e4342da44a
3 changed files with 23 additions and 19 deletions
+8 -8
View File
@@ -72,15 +72,14 @@ needs, listing and detail alike:
| Field | Source | Notes | | Field | Source | Notes |
|---|---|---| |---|---|---|
| `id` | — | Newly marked **filterable** — needed for `getById()`'s `id = "..."` filter; Meilisearch doesn't filter on the primary key by default. | | `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. | | `collections` | `$product->collections` | Array of `{id, name}` — `name` is the translated collection name. Filterable on the nested field `collections.id`, not `collections` itself. |
| `collection_names` | `$product->collections` | Display only, not filterable — translated collection names. |
| `slugs` | `$product->urls->pluck('slug')` | Filterable. Every locale's `Url::slug` for the product, so `getBySlug()` resolves purely from the index — no database read. | | `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. | | `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. | | `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. | | `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. | | `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). | | `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 `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. "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 ## Reviews
`Modules\Core\Review\Models\ProductReview` (`product_reviews` table) is indexed per-product as `Modules\Core\Review\Models\ProductReview` (`product_reviews` table) is indexed per-product under
`reviews` (array), plus `review_count` and `average_rating` (rounded to 1 decimal, `null` if the a single `reviews` key: `{items, count, average_rating}` — `items` is the array of reviews,
product has no reviews). Only public-safe fields are included — **`reviewer_email` is deliberately `average_rating` is rounded to 1 decimal (`null` if the product has no reviews). Only public-safe
excluded**, it's PII with no storefront use. `reply`/`replied_at` (the staff response) are fields are included on each item — **`reviewer_email` is deliberately excluded**, it's PII with no
included, since they're meant to be shown alongside the review. 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) 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. — its own save doesn't touch the `Product` row, so the product's own model events never fire.
+14 -10
View File
@@ -16,7 +16,7 @@ use Spatie\MediaLibrary\MediaCollections\Models\Media;
* Extends Lunar's own indexer so Modules\Core\Product\Services\ProductService can * Extends Lunar's own indexer so Modules\Core\Product\Services\ProductService can
* serve both listing/filtering AND single-product lookups from Meilisearch alone — * 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: * 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 * - slugs (every locale's Url::slug for the product, filterable) — lets
* ProductService::getBySlug() resolve a product from the index directly, with * ProductService::getBySlug() resolve a product from the index directly, with
* no database read at all * no database read at all
@@ -24,9 +24,9 @@ use Spatie\MediaLibrary\MediaCollections\Models\Media;
* - variants: sku, stock, purchasable, option values, prices, media * - variants: sku, stock, purchasable, option values, prices, media
* - the full media gallery (not just the single thumbnail Lunar's base indexer sends) * - the full media gallery (not just the single thumbnail Lunar's base indexer sends)
* - tags * - tags
* - reviews: public-safe fields only (see mapReview() — reviewer_email is deliberately * - reviews: {items: [...], count, average_rating} — items are public-safe fields
* excluded, it's PII with no storefront use), including staff replies, plus an * only (see mapReview() — reviewer_email is deliberately excluded, it's PII with
* average rating * no storefront use), including staff replies
* - channel_ids (filterable) — Lunar's base indexer only indexes "status" as * - channel_ids (filterable) — Lunar's base indexer only indexes "status" as
* filterable, not channel assignment, so search results can't otherwise be * filterable, not channel assignment, so search results can't otherwise be
* scoped to products actually assigned+enabled on the current sales channel * scoped to products actually assigned+enabled on the current sales channel
@@ -49,7 +49,7 @@ class ProductIndexer extends BaseProductIndexer
...parent::getFilterableFields(), ...parent::getFilterableFields(),
'id', 'id',
'brand', 'brand',
'collections', 'collections.id',
'price', 'price',
'slugs', 'slugs',
'channel_ids', 'channel_ids',
@@ -85,16 +85,20 @@ class ProductIndexer extends BaseProductIndexer
$currency = Currency::getDefault(); $currency = Currency::getDefault();
$reviews = ProductReview::where('product_id', $model->id)->with('media')->get(); $reviews = ProductReview::where('product_id', $model->id)->with('media')->get();
$data['collections'] = $model->collections->pluck('id')->map(fn ($id) => (string) $id)->all(); $data['collections'] = $model->collections->map(fn ($collection) => [
$data['collection_names'] = $model->collections->map(fn ($collection) => $collection->translateAttribute('name'))->all(); 'id' => $collection->id,
'name' => $collection->translateAttribute('name'),
])->all();
$data['slugs'] = $model->urls->pluck('slug')->unique()->values()->all(); $data['slugs'] = $model->urls->pluck('slug')->unique()->values()->all();
$data['tags'] = $model->tags->pluck('value')->all(); $data['tags'] = $model->tags->pluck('value')->all();
$data['media'] = $model->media->map(fn (Media $media) => $this->mapMedia($media))->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['variants'] = $model->variants->map(fn (ProductVariant $variant) => $this->mapVariant($variant, $currency))->all();
$data['price'] = $this->cheapestPrice($model, $currency); $data['price'] = $this->cheapestPrice($model, $currency);
$data['reviews'] = $reviews->map(fn (ProductReview $review) => $this->mapReview($review))->all(); $data['reviews'] = [
$data['review_count'] = $reviews->count(); 'items' => $reviews->map(fn (ProductReview $review) => $this->mapReview($review))->all(),
$data['average_rating'] = $reviews->isEmpty() ? null : round($reviews->avg('rating'), 1); 'count' => $reviews->count(),
'average_rating' => $reviews->isEmpty() ? null : round($reviews->avg('rating'), 1),
];
$data['channel_ids'] = $model->channels() $data['channel_ids'] = $model->channels()
->wherePivot('enabled', true) ->wherePivot('enabled', true)
->pluck('lunar_channels.id') ->pluck('lunar_channels.id')
+1 -1
View File
@@ -157,7 +157,7 @@ class ProductService
} }
$clauses = Collection::make([ $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->brand !== null ? 'brand = "'.addcslashes($filters->brand, '"\\').'"' : null,
$filters->minPrice !== null ? "price >= {$filters->minPrice}" : null, $filters->minPrice !== null ? "price >= {$filters->minPrice}" : null,
$filters->maxPrice !== null ? "price <= {$filters->maxPrice}" : null, $filters->maxPrice !== null ? "price <= {$filters->maxPrice}" : null,