Compare commits

..
20 Commits
Author SHA1 Message Date
arvanitakis 63caaf55c7 Bump Version to 0.6.1 2026-08-27 11:39:43 +03:00
arvanitakis e4342da44a Fix: Correcting shape of indexed products 2026-08-27 11:32:20 +03:00
arvanitakis e95ea4a43c Fix: Updating Product Options Registry and adding handle to the meilisearch index 2026-08-27 11:14:56 +03:00
arvanitakis cb3fe095d9 Bump Version to 0.6.0 2026-08-27 01:57:43 +03:00
arvanitakis eef473dbd2 Feature: Product option types to support multiple cases 2026-08-27 01:55:58 +03:00
arvanitakis 409db9c7bf Fix: Correcting Language Line fallback resolver 2026-08-27 01:28:37 +03:00
arvanitakis 594fa41527 Feature: Localization Restructuring to follow a stricter pattern 2026-08-27 01:08:34 +03:00
arvanitakis ef356a6397 Feature: Products Restructuring to follow a strict rule 2026-08-27 01:05:04 +03:00
arvanitakis b3b5ca740d Feature: Resolving translated fields based on current locale, while also resturning a correct length aware paginator for the results 2026-08-27 00:48:38 +03:00
arvanitakis d01d27f7ea Hotfix: Correcting ProductResolver when importing Products 2026-08-27 00:46:43 +03:00
arvanitakis b86fe78852 Hotfix: Adding a correct media conversions to handle small review images 2026-08-27 00:34:39 +03:00
arvanitakis 0edf7b156d Feature: Updating Product Service to handle multiple locales, fetching the correct locale, or, the default locale as fallback 2026-08-26 23:52:33 +03:00
arvanitakis ef9e9daab0 Bump version to 0.5.4 2026-08-26 18:34:26 +03:00
arvanitakis 751aff5939 Feature: Adding Sorting To Product List 2026-08-26 18:05:15 +03:00
arvanitakis 09844ec2b5 Bump Version to 0.5.3 2026-08-26 14:29:29 +03:00
arvanitakis 885923380d Hotfix: Selcting correct table name for channel Id 2026-08-26 14:29:08 +03:00
arvanitakis 235fdda4a7 Bump Version to 0.5.2 2026-08-26 13:28:08 +03:00
arvanitakis c2eb9bd66a Hotfix: Updating LocaleMiddleware to handle more than 2 languages 2026-08-26 13:26:29 +03:00
arvanitakis 356fbd73c5 Bump Version to 0.5.1 2026-08-26 13:16:07 +03:00
arvanitakis fa137c9a79 Feature: Adding back ChannelIds on Product Indexer 2026-08-26 13:14:32 +03:00
65 changed files with 515 additions and 2798 deletions
+62
View File
@@ -4,6 +4,68 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [0.6.1] - 2026-08-27
### Added
- `Modules\Core\Product\Contracts\ProductOptionTypeInterface` describes how a category of `Lunar\Models\ProductOption` (e.g. "Color", "Size") behaves — what structured data its values carry in their free-form `meta` jsonb column, and how an admin edits it via Filament — without introducing a new model. Registered via `Modules\Core\Product\Services\ProductOptionTypeManager::get()->register([...])` (a singleton registry, same shape as `Modules\Core\Notification\NotificationRegistry`) from a service provider's `boot()`. An admin then picks one per `ProductOption` from an "Option Type" dropdown on the option's own edit form (added by `Modules\Core\Product\Filament\Extensions\ProductOptionResourceExtension`), stored in `ProductOption::meta['option_type']` — deliberately not tied to the option's `handle`, since a shop's own handle naming shouldn't have to match a type's key. `Modules\Core\Product\Filament\Extensions\ValuesRelationManagerExtension` hooks Lunar's own `ValuesRelationManager` (both extensions via `LunarPanel::extensions()`, registered in `CorePlugin`) to append the resolved type's meta form fields to the stock "Values" tab — no fork of Lunar's classes needed. Ships a reference implementation, `Modules\Core\Product\OptionTypes\ColorOptionType`, registered automatically by the new `Modules\Core\Providers\ProductServiceProvider`. Documented in `docs/product-options.md`.
- `Modules\Core\Product\Services\ProductIndexer::mapVariant()` now includes each option's `handle` (alongside its translated name) in a variant's indexed `options[]` — previously only the translated `option`/`value` names and `meta` were indexed, with no stable, locale-independent identifier for which option a value belongs to.
- `Modules\Core\Product\Observers\ProductOptionReindexObserver`, wired in the new `Modules\Core\Providers\ProductServiceProvider`, keeps Meilisearch in sync when a `ProductOption` or `ProductOptionValue` is saved or deleted — e.g. picking an Option Type or editing a color's hex. `ProductIndexer::mapVariant()` embeds each option value's `meta` directly into a product's indexed document, but saving the option/value never fires the *product's* own save events, so without this a changed hex would only reach the index on that product's next unrelated reindex. The observer resolves every `Lunar\Models\Product` whose variants use the changed option (or option value) via the `product_option_value_product_variant` pivot, and calls `->searchable()` on each.
### Changed
- **Breaking:** `Modules\Core\Product\Services\ProductIndexer`'s indexed `collections` field is now an array of `{id, name}` objects instead of two parallel arrays (`collections` as bare ID strings, `collection_names` as translated names joined only by array index). `collection_names` is removed. Filtering by collection now targets the nested field `collections.id` (Meilisearch supports filtering on nested object fields), not bare `collections` — `Modules\Core\Product\Services\ProductService::buildFilter()` updated accordingly; `ProductFilters(collectionId: ...)`'s public API is unchanged. Run `php artisan lunar:meilisearch:setup` then `lunar:search:index --refresh` after upgrading (see docs/product-listing.md "Gotchas").
- **Breaking:** `ProductIndexer`'s indexed `review_count`/`average_rating` top-level keys are folded into the existing `reviews` key: `reviews` is now `{items, count, average_rating}` instead of a bare array with `review_count`/`average_rating` as separate sibling keys. `reviews` (the array of review items) moved to `reviews.items`.
## [0.6.0] - 2026-08-27
### Added
- `Modules\Core\Localization\Models\LanguageLine` extends `spatie/laravel-translation-loader`'s `LanguageLine` to fall back to the store's actual default language (`LanguageCache::defaultLocale()`, backed by Lunar's `languages.default` flag) instead of the package's stock behavior of falling back to the static `config('app.fallback_locale')` — the two were previously disconnected, so changing the default language via the Filament **Languages** resource had no effect on which locale an untranslated storefront label silently fell back to. Swapped in automatically via `config('translation-loader.model')` in `LocalizationServiceProvider::register()`; no consuming app changes needed. Documented in `docs/localization.md` ("Fallback locale follows the store's default language").
### Changed
- **Breaking:** `Modules\Core\Catalog\ProductService::list()` now returns a real `Illuminate\Pagination\LengthAwarePaginator` (built from the localized Meilisearch hits) instead of a plain `array{data, meta}` — gives callers normal Laravel pagination behaviour (`$products->links()`, standard JSON serialization) without ever touching Scout's raw `paginateRaw()` response directly. `getById()`/`getBySlug()` are unaffected (still return `?array`).
- `ProductService::withLocalizedFields()` (used by `list()`, `getById()`, `getBySlug()`) no longer hardcodes `name`/`description` as the only translated fields — it now reads every `TranslatedText` attribute on `Product` from `Lunar\Base\AttributeManifest` (the same source Lunar's own indexer reads), so a store's own custom translated attributes (e.g. `seo_title`, `seo_description`) are resolved and locale-stripped automatically with no code change here. Raw `{handle}_{locale}` keys (e.g. `name_el`, `seo_title_en`) are now stripped from every returned product, not just `name_*`/`description_*`.
- Extracted `Modules\Core\Localization\Services\LanguageCache` (cached read layer over Lunar's `languages` table: `all()`, `defaultLocale()`, `availableLocales()`, `forget()`) out of `LocaleMiddleware`, which previously owned this as private/static methods despite not being middleware-specific behavior. `LocaleMiddleware` now takes `LanguageCache` via constructor injection. `LocaleMiddleware::defaultLocale()`/`forgetLanguagesCache()` (static) are removed — use `app(LanguageCache::class)` or inject `LanguageCache` directly.
### Fixed
- `Modules\Core\MigrateImport\JudgeMe\Resolvers\ProductResolver::resolve()` picked whichever `lunar_urls` row matched a slug first, which can be a soft-deleted product left behind by an earlier import batch rather than the current live one — a store can easily end up with more than one `Product` row sharing the same slug across re-imports, since a soft-deleted product's URL row isn't cleaned up. This silently broke every downstream lookup for that handle (e.g. `Modules\Core\MigrateImport\JudgeMe\JudgeMeExportImporter` logging "no product found for handle, skipping review" and dropping the row, even though a live product with that exact handle existed). Rewrote as a join against `lunar_products` — via `Product::query()`, so Eloquent's `SoftDeletes` global scope excludes trashed rows — so only a URL pointing at a live product resolves.
- `Modules\Core\Review\Models\ProductReview` had no `registerMediaConversions()` at all, unlike `Product`/`ProductVariant` which get one automatically from Lunar's own `Lunar\Base\StandardMediaDefinitions`. `Modules\Core\Search\ProductIndexer::mapMedia()` is shared across product, variant, and review media and always requests the `small` conversion — the first time a review had an attached image, indexing it threw `Spatie\MediaLibrary\MediaCollections\Exceptions\InvalidConversion`, silently failing the product's `MakeSearchable` queue job (and everything queued after it, since Scout batches). Added a matching `small` conversion (300×300, same fit/border/background as Lunar's standard one) directly on `ProductReview`.
### Breaking
- Merged `Modules\Core\Catalog` and `Modules\Core\Search` into a single `Modules\Core\Product` concern, since both existed purely to serve `Product` (browsing/filtering vs. indexing/full-text search — two services, one concern), following a stricter subfolder convention (`Contracts/`, `Enums/`, `Services/`, `DTOs/`, `Models/`, etc. per concern) going forward:
- `Modules\Core\Catalog\ProductService` → `Modules\Core\Product\Services\ProductService`
- `Modules\Core\Catalog\ProductFilters` → `Modules\Core\Product\DTOs\ProductFilters`
- `Modules\Core\Catalog\ProductSort` → `Modules\Core\Product\Enums\ProductSort`
- `Modules\Core\Search\ProductIndexer` → `Modules\Core\Product\Services\ProductIndexer`
- `Modules\Core\Search\ProductSearchService` → `Modules\Core\Product\Services\ProductSearchService`
Consuming apps must update any direct references — notably `config/lunar/search.php`'s `'indexers'` map, which points at `ProductIndexer` by FQCN. `Modules\Core\Catalog\ProductOptionTypeInterface` (in-progress, not yet wired to anything) was deliberately left in place rather than moved.
- Reorganized `Modules\Core\Localization` under the same stricter per-concern subfolder convention — `Events/`, `Filament/`, `Listeners/` were already correctly categorized; four loose root files moved into typed buckets by structural role:
- `Modules\Core\Localization\LocaleMiddleware` → `Modules\Core\Localization\Middleware\LocaleMiddleware`
- `Modules\Core\Localization\LanguageCacheObserver` → `Modules\Core\Localization\Observers\LanguageCacheObserver`
- `Modules\Core\Localization\TranslationReader` → `Modules\Core\Localization\Services\TranslationReader`
- `Modules\Core\Localization\TranslationService` → `Modules\Core\Localization\Services\TranslationService`
`Modules\Core\Localization\Services\LanguageCache` (added earlier in this same unreleased version) already lived at its correct final path — unaffected. The `'locale'` route-middleware alias (registered in `LocalizationServiceProvider`) is unaffected for consuming apps using it by string alias rather than FQCN.
## [0.5.4] - 2026-08-26
### Added
- `Modules\Core\Catalog\ProductService::list()` accepts a `sort` parameter (new `ProductSort` enum: `PriceAsc`, `PriceDesc`, `Newest`), translated into a Meilisearch `sort` clause — `list()` previously had no way to order results, since it always searches with an empty query string and so has no relevance score to fall back on. `Modules\Core\Search\ProductIndexer::getSortableFields()` now also marks `price` sortable (Lunar's base indexer only marks `created_at`/`updated_at`/`skus`/`status`). Requires re-syncing index settings (`php artisan lunar:meilisearch:setup`) on existing stores. Documented in `docs/product-listing.md` ("Sorting").
## [0.5.3] - 2026-08-26
### Fixed
- `Modules\Core\Search\ProductIndexer::toSearchableArray()` threw `column reference "id" is ambiguous` on Postgres when computing `channel_ids` — `$model->channels()->wherePivot('enabled', true)->pluck('id')` joins `lunar_channels` and `lunar_channelables`, both of which have an `id` column, and the unqualified `pluck('id')` left Postgres unable to resolve which table's column to select (SQLite/MySQL tolerated the ambiguity). Qualified as `pluck('lunar_channels.id')`.
## [0.5.2] - 2026-08-26
### Fixed
- `Modules\Core\Localization\LocaleMiddleware`'s shared view data only ever surfaced a single alternate locale (`altLocale`/`altLocaleUrl`, found via `firstWhere('code', '!=', $current)`) — correct by coincidence for a 2-language store, but silently dropped every locale past the first "other" one found for a 3+ language store, with no error. Replaced with `altLocales`, a collection of every other configured language (`code`, `name`, `url` for the current route each), so a language switcher or `hreflang` tags scale to any number of locales. Documented in `docs/localization.md` ("Shared view data — language switcher and `hreflang` tags").
## [0.5.1] - 2026-08-25
### Added
- `Modules\Core\Search\ProductIndexer` now indexes `channel_ids` (filterable) — Lunar's base indexer only marks `status` as filterable, not channel assignment, so storefront search couldn't otherwise scope results to products actually assigned and enabled on the current sales channel. Computed from `$product->channels()->wherePivot('enabled', true)`. Ported from an older `Products` branch whose remote had been deleted; the branch's other, now-superseded `ProductIndexer` changes were dropped in favor of the richer indexer already on `master` (collections, price, variants, reviews — see `0.5.0`).
## [0.5.0] - 2026-08-24
### Added
+21 -117
View File
@@ -1,9 +1,6 @@
# Core Module
A Laravel module providing authentication, localization, product search/catalog, privacy/GDPR
tooling, notifications, activity logging, CLI tooling, and functional types on top of the
[Lunar](https://lunarphp.io) e-commerce package. Designed to be consumed as a standalone Composer
package by any Lunar-based e-shop.
A Laravel module providing authentication, notifications, activity logging, CLI tooling, and functional types on top of the [Lunar](https://lunarphp.io) admin panel. Designed to be consumed as a standalone Composer package.
---
@@ -11,83 +8,13 @@ package by any Lunar-based e-shop.
### OTP Authentication
Passwordless login for both staff (Lunar panel) and customers via 6-digit codes delivered by
email. Codes expire after 10 minutes, rate-limited to 5 attempts. The Lunar panel login page is a
two-step flow (email → OTP) with a back button to return from the code step to the email step.
Passwordless login for both staff (Lunar panel) and customers via 6-digit codes delivered by email. Codes expire after 10 minutes. The Lunar panel login page is a two-step flow: email → OTP. Rate-limited to 5 attempts.
See [`docs/otp-auth.md`](docs/otp-auth.md).
### Localization
Locale-prefixed routing (`Modules\Core\Localization\LocaleMiddleware`) — a `locale` route
middleware, opt-in per shop, that resolves and redirects to the correct language segment
(`/el/...`, `/en/...`) based on Lunar's own language list, with caching and rename-safe
translation migration. Also brings in storefront UI label translations
(`spatie/laravel-translation-loader`) with an admin-editable `LanguageLine` resource.
See [`docs/localization.md`](docs/localization.md).
### Product Search & Catalog
Two complementary services on top of Meilisearch:
- **`Modules\Core\Search\ProductSearchService`** — locale-aware full-text product search.
- **`Modules\Core\Catalog\ProductService`** — listing/filtering (by collection, brand, price
range) and single-product lookup by id or slug, reading directly from the Meilisearch index
rather than the database.
Both are backed by `Modules\Core\Search\ProductIndexer`, which extends Lunar's own indexer with
collections, price, variants, media, tags, and reviews — everything needed for both a listing
page and a full product detail page from one index.
See [`docs/product-search.md`](docs/product-search.md) and
[`docs/product-listing.md`](docs/product-listing.md).
### Product Reviews
`Modules\Core\Review\ProductReview` — ratings/reviews with staff replies, a Filament sub-navigation
page on the product edit screen, and automatic re-indexing (via `ReviewServiceProvider`) whenever
a review is created, updated, or deleted, so a product's Meilisearch document never goes stale.
### Privacy / GDPR Data-Subject Requests
Right of access (export) and right of erasure, built as an extensible contract
(`Modules\Core\Privacy\Contracts\PersonalDataProvider`) rather than a fixed table list — any
module can register its own data without core knowing it exists.
- **Two independent scopes**: erasing/exporting a Lunar `Customer` (business account) is never
the same operation as erasing/exporting a `User` (individual login) — a `Customer` erasure
never touches any linked `User`'s login, and a `User` erasure never touches a `Customer`
account's own data. See `docs/privacy.md` "User-scope vs Customer-scope".
- **Cancellable grace period** (default 30 days, configurable) before anything is actually
erased — logging back in during the window automatically reverts the request, mirroring
Shopify's own account-deletion flow. Immediate erasure exists but is staff-only by type, never
reachable from a self-service flow.
- **Sole-owner cascade**: erasing the last remaining `User` on a `Customer` also opens a (grace
period) erasure request for that now-orphaned `Customer`, so its PII doesn't sit unreachable
forever — traced back to the triggering request so login-reactivation can revert exactly that
cascade.
- **Queued export**: gathering data and writing a CSV-per-provider zip (via the generic,
reusable `Modules\Core\Export\CsvWriter`) runs as a background job; a consuming app hooks its
own notification onto the completion event via the Notification Registry (below).
See [`docs/privacy.md`](docs/privacy.md).
### Shopify Migration
`Modules\Core\MigrateImport\Shopify\ShopifyExportImporter` — imports a Shopify CSV product export
(products, variants, images, collections, tags, prices) into Lunar, idempotently re-runnable via
an `import_mappings` table. Part of a source-agnostic import framework
(`boboko:migrate:import`) designed to support additional sources later.
See [`docs/shopify-import.md`](docs/shopify-import.md).
### Notification Registry
An event-driven notification system. Each notification class declares which event it listens to
and who to notify — the registry wires up the listener automatically. All notifications extend
`BaseNotification`, which implements `ShouldQueue`, so delivery is async. Supports optional
delays.
An event-driven notification system. Each notification class declares which event it listens to and who to notify — the registry wires up the listener automatically. All notifications extend `BaseNotification` which implements `ShouldQueue`, so delivery is async. Supports optional delays.
**Creating a notification:**
@@ -106,13 +33,9 @@ class MyNotification extends BaseNotification
NotificationRegistry::get()->register([MyNotification::class]);
```
See [`docs/notifications.md`](docs/notifications.md).
### Activity Logging
Thin wrapper around [Spatie Laravel Activity Log](https://github.com/spatie/laravel-activitylog).
Four standardized methods: `created()`, `updated()`, `failed()`, `deleted()`. Logs to the `lunar`
channel and auto-resolves the actor from the staff session.
Thin wrapper around [Spatie Laravel Activity Log](https://github.com/spatie/laravel-activitylog). Four standardized methods: `created()`, `updated()`, `failed()`, `deleted()`. Logs to the `lunar` channel and auto-resolves the actor from the staff session.
See [`docs/activity-log.md`](docs/activity-log.md).
@@ -120,11 +43,8 @@ See [`docs/activity-log.md`](docs/activity-log.md).
- Custom OTP login page replacing the default Lunar panel login
- `StaffResourceExtension` — removes password field from Lunar's staff resource
- `CustomerResourceExtension` — replaces default address relation manager with a custom
implementation
- Table-rate shipping (`ShippingPlugin`) registered by default
- `CorePlugin` — configures panel path, branding, logos, navigation items, and activity log
field exclusions for staff
- `CustomerResourceExtension` — replaces default address relation manager with a custom implementation
- `CorePlugin` — configures panel path, branding, logos, navigation items, and activity log field exclusions for staff
Register the plugin in your Lunar panel provider:
@@ -132,36 +52,30 @@ Register the plugin in your Lunar panel provider:
->plugin(\Modules\Core\CorePlugin::make())
```
See [`docs/lunar.md`](docs/lunar.md) for the full Lunar reference and non-obvious gotchas hit
while building against it.
### CLI Commands
| Command | Description |
|---|---|
| `boboko:anonymize` | Dummy-scrub personal data in `users`/`lunar_customers` for local dev safety (local environment only — **not** the GDPR erasure tool; see Privacy above for that) |
| `boboko:export` | Dump database + storage files to a timestamped zip |
| `boboko:import` | Restore from a `boboko:export` zip archive |
| `boboko:export:cleanup` | Delete old export zips, keep N most recent |
| `boboko:migrate:import` | Import a vendor product catalog (Shopify, etc.) into Lunar |
| `boboko:privacy:process-erasure-requests` | Dispatch an erasure job for every due GDPR erasure request (wire into your own scheduler) |
| `lunar:create-admin` | Create a Lunar admin user (overrides Lunar's own command) |
| `lunar:install` | Seed default Lunar store data — countries, channel, currency, tax zone, attributes, product type (overrides Lunar's own command) |
| `core:create-admin` | Create a Lunar admin user |
| `core:anonymize` | GDPR anonymization of users and customers (local only) |
| `core:export` | Dump database + storage files to a timestamped zip |
| `core:import` | Restore from a zip export (runs anonymize automatically, local only) |
| `core:export-cleanup` | Delete old export zips, keep N most recent |
### Functional Types
Result and Option types for explicit error handling without exceptions.
Result and Option monads for explicit error handling without exceptions.
```php
// Result<T, E>
$result = Success::create($value);
$result = Error::create('something went wrong');
$result = Success::of($value);
$result = Error::of('something went wrong');
$result->map(fn($v) => ...)->flatMap(fn($v) => ...);
// Option<T>
$option = Some::create($value);
$option = None::create();
$option->map(fn($v) => ...);
$option = Option::fromValue($nullableValue);
$option->getOrElse('default');
$option->map(fn($v) => ...)->filter(fn($v) => $v > 0);
```
---
@@ -188,22 +102,17 @@ Then run:
```bash
composer require boboko/core
php artisan vendor:publish --tag=core-config
php artisan vendor:publish --tag=core-assets
php artisan migrate
```
For local core development alongside a consuming app (path-repo symlink + Docker mount), see
[`docs/modules.md`](docs/modules.md) "Docker Compose: the local-core mount".
---
## Requirements
- PHP 8.5+
- Laravel 12+
- Lunar 1.3 (`lunarphp/lunar`)
- Meilisearch (for product search/listing/catalog)
- PHP 8.2+
- Laravel 11+
- Lunar (lunarphp/lunar + lunarphp/admin)
- Spatie Laravel Activity Log
---
@@ -211,12 +120,7 @@ For local core development alongside a consuming app (path-repo symlink + Docker
## Documentation
- [`docs/otp-auth.md`](docs/otp-auth.md) — OTP authentication flow
- [`docs/localization.md`](docs/localization.md) — Locale-prefixed routing and storefront translations
- [`docs/product-search.md`](docs/product-search.md) — Full-text product search
- [`docs/product-listing.md`](docs/product-listing.md) — Product listing/filtering/detail catalog service
- [`docs/privacy.md`](docs/privacy.md) — GDPR right of access/erasure, User-scope vs Customer-scope
- [`docs/shopify-import.md`](docs/shopify-import.md) — Shopify CSV → Lunar field mapping and import design
- [`docs/activity-log.md`](docs/activity-log.md) — Activity logging
- [`docs/lunar.md`](docs/lunar.md) — Lunar framework reference
- [`docs/notifications.md`](docs/notifications.md) — Notification registry
- [`docs/lunar.md`](docs/lunar.md) — Lunar framework reference and gotchas
- [`docs/modules.md`](docs/modules.md) — Module architecture, Customer/User pairing, provider registration pitfalls
+3 -3
View File
@@ -2,7 +2,7 @@
"name": "boboko/core",
"description": "Core module — authentication and shared panel behaviour",
"type": "library",
"version": "0.5.0",
"version": "0.6.1",
"autoload": {
"psr-4": {
"Modules\\Core\\": "src/"
@@ -36,8 +36,8 @@
"Modules\\Core\\Providers\\AuthServiceProvider",
"Modules\\Core\\Providers\\CustomerServiceProvider",
"Modules\\Core\\Providers\\LocalizationServiceProvider",
"Modules\\Core\\Providers\\ReviewServiceProvider",
"Modules\\Core\\Providers\\PrivacyServiceProvider"
"Modules\\Core\\Providers\\CatalogServiceProvider",
"Modules\\Core\\Providers\\ReviewServiceProvider"
]
}
},
-29
View File
@@ -16,33 +16,4 @@ return [
'auto_create_customer_for_user' => true,
/*
|--------------------------------------------------------------------------
| Privacy / GDPR data-subject requests
|--------------------------------------------------------------------------
|
| 'providers' lists every Modules\Core\Privacy\Contracts\PersonalDataProvider
| that should be consulted for right-of-access/right-of-erasure requests. A
| module never needs to be known to core in advance — it just adds its own
| provider class here, the same way config('lunar.search.indexers') maps a
| model to its indexer. See docs/privacy.md.
|
| 'grace_period_days' is how long an erasure request stays cancellable
| (account deactivated, not yet erased) before it's actually processed by
| the privacy:process-erasure-requests scheduled command.
|
*/
'privacy' => [
'providers' => [
\Modules\Core\Privacy\Providers\CustomerDataProvider::class,
\Modules\Core\Privacy\Providers\AddressDataProvider::class,
\Modules\Core\Privacy\Providers\OrderDataProvider::class,
\Modules\Core\Privacy\Providers\CartDataProvider::class,
\Modules\Core\Privacy\Providers\ReviewDataProvider::class,
],
'grace_period_days' => 30,
],
];
@@ -1,22 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->timestamp('deactivated_at')->nullable()->after('otp_expires_at');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('deactivated_at');
});
}
};
@@ -1,56 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('data_erasure_requests', function (Blueprint $table) {
$table->id();
// Polymorphic, not a fixed customer_id — a request targets either a
// Lunar Customer (business account) or a User (individual), never
// both at once. See docs/privacy.md "User-scope vs Customer-scope".
$table->string('subject_type');
$table->unsignedBigInteger('subject_id');
// Snapshot, not a live-looked-up value — the subject's email may
// change or the record may be gone by the time this is read.
$table->string('email')->nullable();
// Who asked for this: the subject themselves (self-service deletion)
// or a staff member acting on their behalf. Plain nullable type+id
// columns rather than morphs() — only ever one of two concrete actor
// types, not an open-ended polymorphic set.
$table->string('requested_by_type');
$table->unsignedBigInteger('requested_by_id');
$table->string('status')->default('pending');
// Set only on a Customer-scoped request that was auto-created because
// erasing a User left them as the sole remaining user on that Customer
// (see Modules\Core\Privacy\Listeners\CascadeCustomerErasureListener).
// Null for every normal, directly-requested erasure. Lets login-
// reactivation find and revert exactly the Customer request THIS
// User's cancellation caused, without touching an unrelated,
// independently-requested Customer erasure the User happens to be
// linked to.
$table->foreignId('caused_by_request_id')->nullable()->constrained('data_erasure_requests')->nullOnDelete();
// now() + config('core.privacy.grace_period_days') at creation time —
// when privacy:process-erasure-requests will actually run this.
$table->timestamp('scheduled_for');
$table->timestamp('cancelled_at')->nullable();
$table->timestamp('completed_at')->nullable();
// Every provider's outcome, written once the request completes —
// see Modules\Core\Privacy\ErasureReport. Null until then.
$table->json('report')->nullable();
$table->timestamps();
$table->index(['status', 'scheduled_for']);
$table->index(['subject_type', 'subject_id']);
});
}
public function down(): void
{
Schema::dropIfExists('data_erasure_requests');
}
};
@@ -1,36 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('data_export_requests', function (Blueprint $table) {
$table->id();
// Polymorphic, not a fixed customer_id — see data_erasure_requests
// for the same shape and reasoning.
$table->string('subject_type');
$table->unsignedBigInteger('subject_id');
// Snapshot, not a live lookup — same reasoning as
// data_erasure_requests.email (see that migration).
$table->string('email')->nullable();
$table->string('status')->default('pending');
// Storage path of the assembled export .zip, set once the queued job
// finishes. Null while pending.
$table->string('file_path')->nullable();
$table->timestamp('completed_at')->nullable();
$table->timestamps();
$table->index('status');
$table->index(['subject_type', 'subject_id']);
});
}
public function down(): void
{
Schema::dropIfExists('data_export_requests');
}
};
+49 -4
View File
@@ -50,7 +50,7 @@ fine — just keep admin/Livewire/webhook routes registered outside of it (as th
## Behavior
`Modules\Core\Localization\LocaleMiddleware`:
`Modules\Core\Localization\Middleware\LocaleMiddleware`:
1. Reads the first path segment (`request()->segment(1)`).
2. Matches it against `Lunar\Models\Language::code`.
@@ -68,7 +68,7 @@ and invalidated automatically. Adding, editing, or removing a language via the F
### How invalidation is wired (event-driven, not the observer itself)
`Modules\Core\Localization\LanguageCacheObserver` observes `Lunar\Models\Language`'s
`Modules\Core\Localization\Observers\LanguageCacheObserver` observes `Lunar\Models\Language`'s
`created`/`updated`/`deleted` Eloquent events, but it's a thin trigger only — it doesn't do any
invalidation work itself. It dispatches one of three events from
`Modules\Core\Localization\Events` (`LanguageCreated`, `LanguageUpdated` — carrying the old
@@ -105,6 +105,33 @@ $language = $request->attributes->get('language'); // Lunar\Models\Language in
Use `$language->id` when querying Lunar's translatable content (e.g. `Url::where('language_id', ...)`).
### Shared view data — language switcher and `hreflang` tags
The middleware also shares two variables with every view, via `View::share()`, so a layout's
language switcher or `hreflang` tags don't have to recompute the language list themselves:
```blade
{{-- current locale --}}
{{ $currentLocale }} {{-- e.g. "el" --}}
{{-- every OTHER configured language, each with its own URL for the current page --}}
@foreach ($altLocales as $altLocale)
<a href="{{ $altLocale['url'] }}" hreflang="{{ $altLocale['code'] }}">{{ $altLocale['name'] }}</a>
@endforeach
```
`$altLocales` is a **collection**, not a single value — deliberately, so it scales to any number
of configured languages rather than assuming exactly two. Each entry is a plain array:
| Key | Description |
|---|---|
| `code` | The language's `Lunar\Models\Language::code` (e.g. `en`) |
| `name` | The language's display name |
| `url` | The **current route**, re-generated with that language's code — via `route($routeName, [...])` when the current request matched a named route, or a bare `/{code}` fallback otherwise |
A 3+ language store gets one `$altLocales` entry per additional language automatically — nothing
about this shape assumes or special-cases a two-language store.
---
## Single-language shops
@@ -151,6 +178,24 @@ namespaced groups so nothing collides. `__()` resolves the translation for whate
`App::getLocale()` currently is, which `LocaleMiddleware` already sets per-request (see
"Behavior" above) — no extra wiring needed between the two systems.
### Fallback locale follows the store's default language, not `config('app.fallback_locale')`
`spatie/laravel-translation-loader`'s stock `LanguageLine::getTranslation()` falls back to
`config('app.fallback_locale')` — a static `.env` value — when a key has no text for the current
locale. That's a second, disconnected "default language" concept: an admin changing the default
language via the Filament **Languages** resource has no effect on it, so an untranslated label
could silently fall back to the wrong language.
`Modules\Core\Localization\Models\LanguageLine` overrides `getTranslation()` to fall back to
`LanguageCache::defaultLocale()` instead — the same `languages.default` flag `LocaleMiddleware`
already treats as the single source of truth. It's swapped in via
`config('translation-loader.model')` (the package's own documented extension point for
"any model that extends `LanguageLine`"), set in `LocalizationServiceProvider::register()` so it
wins regardless of provider boot order (Laravel's `mergeConfigFrom()` only fills in config keys
not already set, so an explicit `register()`-time set always beats the package's own default).
No consuming app configuration needed — this is automatic once `LocalizationServiceProvider` is
registered.
### Seeding
A starter set of common e-shop labels (`nav.*`, `cart.*`, `product.*`, `auth.*`, `search.*`,
@@ -168,13 +213,13 @@ a third language automatically adds a third input, no resource changes needed.
### `TranslationService` — writes go through here, not the model directly
`Modules\Core\Localization\TranslationService` wraps create/update/delete on `LanguageLine` and
`Modules\Core\Localization\Services\TranslationService` wraps create/update/delete on `LanguageLine` and
dispatches a domain event after each write, following this project's standard event-driven
pattern (see `modules.md`'s "Splitting Service Providers" / event-listener convention —
the same shape as `Modules\Core\Auth\Events\UserCreated`):
```php
use Modules\Core\Localization\TranslationService;
use Modules\Core\Localization\Services\TranslationService;
app(TranslationService::class)->create('storefront', 'nav.wishlist', [
'en' => 'Wishlist',
+2 -2
View File
@@ -1206,6 +1206,6 @@ Real bugs/traps hit while building against Lunar in this package — not obvious
- **`ProductOption.handle` must be unique and non-null if a product has more than one option.** Lunar's Filament variant-switcher widget does `SelectFilter::make($option->handle)` per option — two options with a `null`/matching handle throws "Filter must have a unique name" as a 500 when opening that product's variant pricing page. Always derive a slug and check uniqueness.
- **`Attribute.position` is per-group, and the panel sorts by it.** Hardcoding `position => 1` for multiple new attributes in the same group makes their order undefined/collide with existing attributes at position 1. Compute `max('position') + 1` per group instead.
- **Currency `decimal_places` isn't always 2.** A seeded/demo currency can have the wrong value (seen: EUR seeded with `decimal_places = 1`), which silently corrupts every price display (`€16.50` renders as `165`). If prices look wrong by a factor of 10, check the currency row before assuming the price-writing code is broken.
- **`Builder::paginateRaw()`'s `items()` is not a hit list on the Meilisearch driver.** It contains the *entire* raw response (`hits`, `query`, `processingTimeMs`, `hitsPerPage`, `page`, `totalPages`, `totalHits`) as one associative array. Treating `$paginator->items()` as a plain list (e.g. `collect($paginator->items())->values()`) silently produces 7 elements — the real hits array happens to land first, the rest are stray scalars from the other response keys — no error, just corrupted data. Pull `$paginator->items()['hits']` explicitly. `total()`/`perPage()`/`currentPage()`/`lastPage()` on the paginator are unaffected. See `Modules\Core\Catalog\ProductService` / `docs/product-listing.md`.
- **`ProductOption`/`ProductOptionValue::$name` is not `attribute_data` — `translateAttribute('name')` silently returns null for them.** Unlike `Product`/`Collection`/`Brand`, their translated `name` is a plain locale-keyed array cast (`AsArrayObject`) directly on the column, not stored in `attribute_data`. `HasTranslations::translateAttribute()` only reads `attribute_data`, so calling it on these two models compiles fine and returns `null` with no error — read the array directly instead (`$value->name[$locale] ?? ...`). See `Modules\Core\Search\ProductIndexer::translatedName()`.
- **`Builder::paginateRaw()`'s `items()` is not a hit list on the Meilisearch driver.** It contains the *entire* raw response (`hits`, `query`, `processingTimeMs`, `hitsPerPage`, `page`, `totalPages`, `totalHits`) as one associative array. Treating `$paginator->items()` as a plain list (e.g. `collect($paginator->items())->values()`) silently produces 7 elements — the real hits array happens to land first, the rest are stray scalars from the other response keys — no error, just corrupted data. Pull `$paginator->items()['hits']` explicitly. `total()`/`perPage()`/`currentPage()`/`lastPage()` on the paginator are unaffected. See `Modules\Core\Product\Services\ProductService` / `docs/product-listing.md`.
- **`ProductOption`/`ProductOptionValue::$name` is not `attribute_data` — `translateAttribute('name')` silently returns null for them.** Unlike `Product`/`Collection`/`Brand`, their translated `name` is a plain locale-keyed array cast (`AsArrayObject`) directly on the column, not stored in `attribute_data`. `HasTranslations::translateAttribute()` only reads `attribute_data`, so calling it on these two models compiles fine and returns `null` with no error — read the array directly instead (`$value->name[$locale] ?? ...`). See `Modules\Core\Product\Services\ProductIndexer::translatedName()`.
- **A running `queue:work` process does not pick up an edited/newly-added Scout indexer class.** It loads PHP classes once at boot and keeps them for the process's lifetime. Symptoms: reindexing commands succeed with no errors, calling `toSearchableArray()` directly (e.g. via `artisan tinker`, which always boots fresh) returns the new fields correctly, but documents written via `$model->searchable()` through the live queue are still missing them. Restart the queue worker after deploying an indexer change — no code fix needed.
-383
View File
@@ -1,383 +0,0 @@
# Privacy / GDPR Data-Subject Requests
`Modules\Core\Privacy` implements the right of access (export) and right of erasure for
customers, as an extensible contract rather than a fixed list of tables — any module (core,
or a future ERP/banking/etc. module) can register its own data without core knowing it exists.
---
## User-scope vs Customer-scope — two genuinely different operations
A Lunar `Customer` (business account: orders, addresses, buyer record) and a `User` (individual
login identity) are linked many-to-many via the `customer_user` pivot (see `docs/modules.md`
"Customer/User Pairing") — **one User can belong to many Customer accounts, and one Customer
account can have many linked Users.** This is the real shape of B2B multi-seat access: a person
can have login access to several separate business accounts, and a business account can have
several employees each with their own login.
That means "delete my personal data" and "delete this business account" are not the same request,
and conflating them is actively wrong:
- **Erasing a Customer must never touch any linked User's login or identity.** Erasing "Acme
Corp" must not deactivate or destroy access for the employees who work there — and must not
touch any *other* Customer account, even one sharing some of the same Users.
- **Erasing a User must never touch any Customer account's own data.** John asking to delete
*his* account must clear his name/email/login wherever it appears — and correctly end his
membership on every Customer he's linked to (detach the pivot) — but must not erase Acme Corp's
orders or addresses, and must not affect any other employee still linked to Acme Corp.
Every part of this module is split along that line — a `PersonalDataProvider`, a `PrivacyService`
method, a request record — is always explicitly **for a Customer** or **for a User**, never both
at once, and never one with an implicit cascade into the other.
---
## Why an extensible contract, not a hardcoded script
A GDPR erasure/export request has to touch every module that holds personal data, but core can't
know in advance what future modules will exist or what data they'll hold — and different data
needs fundamentally different handling (freely erasable PII vs. financial records that must be
pseudonymized-not-deleted for legal retention vs. data that must be retained outright). There's
deliberately no central taxonomy for this in the contract — each module owns its own retention
judgment, since only the module that owns a table actually knows its legal requirements.
`Modules\Core\Privacy\Contracts\PersonalDataProvider` is the whole contract:
```php
interface PersonalDataProvider
{
public function name(): string;
public function exportForUser(UserSubject $subject): ProviderExportResult;
public function exportForCustomer(CustomerSubject $subject): ProviderExportResult;
public function eraseForUser(UserSubject $subject): ProviderErasureResult;
public function eraseForCustomer(CustomerSubject $subject): ProviderErasureResult;
}
```
Every provider implements all four methods. A provider with nothing relevant to one scope
implements that method as a no-op — `ErasureOutcome::Skipped` with a reason for erase, an empty
payload for export (e.g. `AddressDataProvider::eraseForUser()`, since addresses belong to a
Customer, not an individual).
A module registers by adding its provider class to `config('core.privacy.providers')` — the
same shape as Lunar's own `config('lunar.search.indexers')` model→indexer map:
```php
// config/core.php
'privacy' => [
'providers' => [
\Modules\Core\Privacy\Providers\CustomerDataProvider::class,
\Modules\Core\Privacy\Providers\AddressDataProvider::class,
\Modules\Core\Privacy\Providers\OrderDataProvider::class,
\Modules\Core\Privacy\Providers\CartDataProvider::class,
\Modules\Core\Privacy\Providers\ReviewDataProvider::class,
// A future module just adds its own provider here.
],
],
```
`PrivacyManager` resolves each class via the container and asserts every `name()` is unique —
two providers registering the same name throws, so a naming collision fails loudly at
resolution time rather than silently overwriting one provider's data in an export/report.
---
## `UserSubject` and `CustomerSubject` — identifying "the person" vs "the account"
Two separate value objects, not one — each deliberately carries only what its own scope needs, so
a provider can't accidentally reach across the boundary:
```php
class CustomerSubject
{
public readonly int $customerId;
// No userIds, no email — Customer-scope has no business knowing about logins.
}
class UserSubject
{
public readonly int $userId;
public readonly ?string $email;
// No customerId — one User can be linked to many Customers; a provider that
// needs to know which ones looks that up itself (e.g. to detach the pivot),
// rather than this value object assuming or privileging any single one.
}
```
`CustomerSubject::forCustomer(Customer $customer)` and `UserSubject::forUser($user)` build one
from the record staff (or the person themselves) look up.
---
## Providers shipped in core
| Provider | `name()` | Covers | Customer-scope | User-scope |
|---|---|---|---|---|
| `CustomerDataProvider` | `customer` | `lunar_customers`, and separately the `User`'s own name/email | Erases the account's own fields only | Erases that User's name/email only, and detaches them from every linked Customer |
| `AddressDataProvider` | `addresses` | `lunar_addresses` | Erased (deleted outright) | Skipped — belongs to a Customer, not an individual |
| `OrderDataProvider` | `orders` | `lunar_orders`, `lunar_order_addresses` | **Pseudonymized, not erased** — see below | Skipped — belongs to a Customer, not an individual |
| `CartDataProvider` | `carts` | `lunar_cart_addresses` | Erased | Skipped — belongs to a Customer, not an individual |
| `ReviewDataProvider` | `reviews` | `product_reviews` | Skipped — authored by an individual, not a business account | Pseudonymized by matching `reviewer_email`; rating/title/body text kept |
`CustomerDataProvider` is the one provider that implements both scopes meaningfully, and keeps
them from touching each other — see the class docblock for the full reasoning.
**`ReviewDataProvider` needs review.** It moved from Customer-scope to User-scope on the
reasoning that authorship is a personal attribute, not a business-account attribute — but this
hasn't been fully validated against how reviews are actually attributed in this codebase. The
class carries a `NEEDS REVIEW` note; revisit before relying on it for a real request.
### Orders are pseudonymized, not deleted
GDPR Art. 17(3)(b) explicitly allows retaining data an erasure request would otherwise cover,
when a legal obligation requires it — tax/accounting law generally requires invoices be kept for
several years. `OrderDataProvider::eraseForCustomer()` clears the free-text PII fields on `Order`/
`OrderAddress` (`customer_reference`, `notes`, name/address/contact fields) but leaves the order
row, totals, line items, and tax data fully intact. Its `ProviderErasureResult` reports
`ErasureOutcome::Pseudonymized`, not `Erased` — a compliance report or admin UI can see exactly
why an order wasn't deleted without reading `OrderDataProvider`'s source.
### Reviews are matched by email — a real, documented limitation
`ProductReview` has no FK to Customer/User at all (see `docs/product-listing.md` "Reviews") —
it's deliberately anonymous, just free-text `reviewer_name`/`reviewer_email`. `ReviewDataProvider`
matches by `reviewer_email` against `UserSubject::$email`; a review submitted under a different
email than the one on file simply won't be found. There's no stronger signal available without
changing `ProductReview`'s schema.
### Staff/employee data is out of scope
`Staff` (admin/panel employees) is never a `UserSubject`/`CustomerSubject` at all — this feature
is scoped to customer-initiated and staff-initiated-on-a-customer's-behalf requests. An employee's
own data (a different HR/access-management concern) isn't reachable through this flow.
---
## Erasure isn't immediate — a cancellable grace period
`PrivacyService` has parallel methods for each scope: `requestErasureForCustomer()` /
`requestErasureForUser()`. Neither erases anything immediately. Each opens a `DataErasureRequest`
(`pending`, `scheduled_for` = now + `config('core.privacy.grace_period_days')`, default 30). This
mirrors Shopify's own account-deletion flow: a window where the subject can change their mind
before anything is actually erased.
**Only the User-scoped request deactivates a login.** `requestErasureForCustomer()` deactivates
no one — a business-account erasure must never block anyone's access.
`requestErasureForUser()` deactivates that one User's login (blocks it — see
`Modules\Core\Auth\Services\UserOtpService` — nothing else changes).
```php
use Modules\Core\Privacy\PrivacyService;
$service = app(PrivacyService::class);
// Customer-scoped: either the Customer itself (self-service) or a Staff member.
$request = $service->requestErasureForCustomer($customer, $requestedBy);
// User-scoped: either the User itself (self-service) or a Staff member.
$request = $service->requestErasureForUser($user, $requestedBy);
// Cancel before scheduled_for — for a User-scoped request, reactivates the
// account. A Customer-scoped request never deactivated anything, so there's
// nothing to reactivate for it.
$service->cancelErasure($request);
```
### Logging back in during the grace period cancels the request automatically
Authentication is never blocked by deactivation — `UserOtpService::validate()` still requires
the correct OTP code. Once validated, it dispatches `Modules\Core\Auth\Events\UserAuthenticated`;
`Modules\Core\Privacy\Listeners\CancelErasureOnLoginListener` (registered in
`PrivacyServiceProvider`, **queued** — see below) looks for a pending request keyed on *that
User's own id* — never a Customer-scoped one, since Customer-scope never deactivates a login in
the first place — and calls `cancelErasure()` on it, then reverts every Customer erasure request
it caused (see "The sole-owner cascade" below). Logging back in **is** the "I changed my mind"
action — no separate UI/flow needed for reactivation.
This listener is queued rather than synchronous, so login returns to the browser without waiting
on the bookkeeping. Nothing else in this codebase currently reads `deactivated_at` besides this
listener and `PrivacyService` itself — `UserOtpService::validate()` never gates the login on it —
so the brief window between the login response and the job actually running has no other consumer
to observe it as stale.
### The sole-owner cascade — erasing the last User on a Customer also erases the Customer
If a User is erased and they were the **only** User linked to a given Customer, that Customer's
data (orders, addresses, buyer record) becomes permanently unreachable through any login the
moment the User's identity is gone — nobody could ever again log in to exercise a data-subject
right over it. GDPR's data minimization principle (Art. 5(1)(c)) means it shouldn't just sit
there indefinitely with no legitimate purpose.
`requestErasureForUser()` and `requestImmediateErasureForUser()` both fire
`Modules\Core\Privacy\Events\UserErasureRequested` right after the request is created (and, for
the immediate path, before `completeErasure()` runs — see below).
`Modules\Core\Privacy\Listeners\CascadeCustomerErasureListener` (**queued**, registered in
`PrivacyServiceProvider`) handles it: for every Customer the User is linked to, if that User is
currently the *sole* linked User (count is 1, and that one User is this one — not just count ===
1, to be explicit rather than relying on an assumption), it opens a second, independent
grace-period request via `requestErasureForCustomer($customer, $user, causedByRequestId: ...)`.
Both requests then run through their own separate 30-day windows.
```
User erasure requested
│
▼
UserErasureRequested event ──▶ CascadeCustomerErasureListener (queued)
│
▼
for each linked Customer: sole owner?
│ yes
▼
requestErasureForCustomer(..., causedByRequestId: <user request id>)
```
**Tracing the cascade — `caused_by_request_id`.** A cascade-created Customer request's
`caused_by_request_id` points back at the User request that triggered it. This is what lets
`CancelErasureOnLoginListener` revert *exactly* the cascade a User's own cancellation should
undo (via `DataErasureRequest::caused()`) without ever touching an unrelated, independently
staff-requested Customer erasure the User happens to still be linked to.
**Why this is queued, not synchronous.** `CascadeCustomerErasureListener` runs as an independent,
separately-retryable job rather than inline inside `requestErasureForUser()` — a failure in the
cascade check never rolls back or blocks the User's own request, and there's no
`DB::transaction()` wrapping needed, since the two writes (the User's request, and any cascaded
Customer request) aren't required to be atomic with each other.
**A known, accepted race on the immediate-erasure path only.** Because the listener is queued,
Eloquent re-fetches its models fresh when the job actually runs (see
`Illuminate\Queue\SerializesModels`) — so `$event->request->subject->customers` reflects the
*real* state at execution time, not a stale snapshot from dispatch time. For
`requestImmediateErasureForUser()`, that job may run before or after `completeErasure()` detaches
the User's memberships in the same call. If the detach happens first, the User is simply no
longer linked to anything by the time the cascade job runs, and nothing cascades — an accepted
race for that rare, staff-only path (see "Immediate erasure" below), not a concern for the
everyday `requestErasureForUser()` grace-period path, where nothing detaches until its own later,
separate `completeErasure()` run — well after the cascade job has had time to fire.
### Processing due requests — one job per request
`php artisan boboko:privacy:process-erasure-requests` finds every `pending` request whose
`scheduled_for` has passed and dispatches one `Modules\Core\Privacy\Jobs\EraseDataSubjectJob` per
request — it does not run `completeErasure()` inline itself. Each job independently calls
`PrivacyService::completeErasure()`, which checks the request's polymorphic `subject` and calls
either every registered provider's `eraseForCustomer()` or `eraseForUser()`, writing the full
per-provider outcome onto the request's `report` column and marking it `completed`. One job per
request means one request's failure (a provider throwing, a DB error) doesn't block or crash
processing of the others, and Laravel's normal per-job retry/failure handling applies to each
request independently. This package doesn't register a schedule itself; each consuming app wires
the command into its own scheduler (daily is reasonable), the same way it owns any other
scheduled task.
### Immediate erasure — staff-only, not self-service
`requestImmediateErasureForCustomer(Customer $customer, Staff $requestedBy): ErasureReport` and
`requestImmediateErasureForUser($user, Staff $requestedBy): ErasureReport` bypass the grace
period entirely and erase right away. Both are `Staff`-only **by type**, not just by convention —
their signatures take `Staff $requestedBy` specifically (not the union type the grace-period
methods accept), so a self-service/customer-facing code path can't reach either one even by
accident; calling with a `Customer`/`User` actor is a compile-time type error, not a runtime
check to remember.
This exists for a formal legal request or regulator inquiry that genuinely requires immediate
action, not as a convenience for an impatient customer. GDPR Art. 17 requires erasure "without
undue delay," but doesn't set a maximum number of days for a grace period, and a short, disclosed,
cancellable hold before executing a self-service request is a widely-used, generally accepted
pattern (the same one Shopify and most major platforms use) — it is **not** offered as a
same-click alternative on the self-service deletion flow, since doing so would mostly defeat the
grace period's purpose (protecting an impulsive requester from themselves). If a subject
explicitly insists on immediate deletion, that's a staff/support decision to make on the record
via one of these methods, not a checkbox exposed to every customer.
```php
$report = $service->requestImmediateErasureForCustomer($customer, $staffMember);
$report = $service->requestImmediateErasureForUser($user, $staffMember);
// Both run synchronously — no queueing, no grace period. $report is the same
// ErasureReport completeErasure() would produce.
```
---
## Export — queued, not synchronous
Export gathers real data across every registered provider — potentially slow, and there's no
reason to block whatever request triggered it (a customer clicking "export my data," an API
call). `requestExportForCustomer()`/`requestExportForUser()` are fast synchronous calls that only
create a `DataExportRequest` row and dispatch the actual work:
```php
$request = $service->requestExportForCustomer($customer);
$request = $service->requestExportForUser($user);
// $request->status is 'pending'; nothing has been gathered yet.
```
### The event chain
1. **`ExportDataSubjectJob`** (queued) checks the request's polymorphic `subject` and calls every
registered provider's `exportForCustomer()` or `exportForUser()` — all sequentially, in this
one job, not fanned out into one job per provider. Per-subject export work is small (a handful
of indexed queries per provider), so there's no real parallelism win, and one job means
"finished" is just "`handle()` returned," with no `Bus::batch()`/completion-counting needed. If
a future provider ever does something genuinely slow (an external API call, a generated PDF),
that's the point to reconsider a per-provider batch — not before.
2. Once every provider's data is gathered, the job fires **`PersonalDataGathered`**
(carries the request and the assembled `ExportReport`) — no file exists yet.
3. **`Modules\Core\Privacy\Listeners\WriteExportToCsvListener`** (registered in
`PrivacyServiceProvider`) handles that event: turns each provider's data into its own CSV (via
the generic `Modules\Core\Export\CsvWriter` — see below), zips them together, writes the zip to
`storage/app/exports/privacy/`, and updates the request (`status: completed`, `file_path`).
This is its own listener — not inline in the job — so the export *format* is swappable (an app
could unregister this and register a JSON-only listener instead) without touching how data is
gathered.
4. Once the file exists, that listener fires **`PersonalDataExportFileWritten`**.
5. Core has no opinion on how the subject is told. A consuming app registers its own notification
against `PersonalDataExportFileWritten` via `Modules\Core\Notification\NotificationRegistry` —
the same pattern as `App\Notifications\QuestionnaireResultsSentNotification` listening on
`App\Events\QuestionnaireResultsSent` (see `boboko-test` for a working example). Core
deliberately does not send an email itself.
### CSV shape
Every provider's `data` is either a list of associative arrays (addresses, orders, reviews — each
item becomes a row) or a single associative array (customer — becomes one row). Any nested array
value within a row (e.g. an order's `addresses` sub-array) is JSON-encoded into that one cell
rather than exploded into further columns — a generic, provider-agnostic rule in
`WriteExportToCsvListener`, not something each provider has to think about.
### `Modules\Core\Export\CsvWriter` — a generic, reusable piece
`CsvWriter::write(array $columns, iterable $rows, string $path)` has no knowledge of GDPR,
customers, or Lunar at all — a caller supplies a schema (`CsvColumn[]`, each just a header plus a
closure that pulls that column's value out of one record) and any iterable data source. It's used
here by `WriteExportToCsvListener`, but is equally usable for an unrelated future need — an admin
bulk catalog export, an accounting handoff — by supplying a different schema and row source;
nothing about it is GDPR-specific.
---
## Audit trail
`DataErasureRequest` (`data_erasure_requests`) and `DataExportRequest` (`data_export_requests`)
are the audit records for erasure and export respectively. Both have a polymorphic `subject`
(`subject_type`/`subject_id`, pointing at either a Lunar `Customer` or a `User` — never both) —
`subject_type`/`subject_id`/`email` are stored as a **snapshot**, not looked up live, since the
whole point is for these tables to remain readable after the record they're about has been
erased. `DataErasureRequest::isForCustomer()` tells you which scope a given request is.
`DataErasureRequest.requested_by_type`/`requested_by_id` capture who asked for it (the subject
themselves, self-service; `Staff` acting on their behalf; or, for a cascade-created Customer
request, the User whose erasure caused it — see "The sole-owner cascade") at request time.
`DataErasureRequest.caused_by_request_id` is set only on a cascade-created Customer request,
pointing back at the User request that triggered it; null on every normal, directly-requested
erasure — see `DataErasureRequest::causedBy()`/`::caused()`.
`DataErasureRequest.report` holds the full per-provider outcome once `completeErasure()` runs;
`DataExportRequest.file_path` points at the generated zip once `WriteExportToCsvListener`
finishes.
**Not yet built**: a standalone "leave/remove from a Customer account" action — unlinking a User
from a Customer without any erasure involved (e.g. a teammate leaving a project, or an account
admin removing someone) — is a related but separate, smaller feature, deliberately out of scope
for this module so far. It shares the same pivot-detach primitive `CustomerDataProvider::
eraseForUser()` already uses as part of a full erasure, but as a standalone action it doesn't
exist yet.
+76 -27
View File
@@ -1,10 +1,10 @@
# Product Listing
`Modules\Core\Catalog\ProductService` provides catalog browsing/filtering AND single-product
`Modules\Core\Product\Services\ProductService` provides catalog browsing/filtering AND single-product
lookup for a storefront — `list()`, `getById()`, `getBySlug()` — all reading directly from the
Meilisearch index rather than the database. One data source for everything this service does.
This is separate from `Modules\Core\Search\ProductSearchService` (see `product-search.md`), which
This is separate from `Modules\Core\Product\Services\ProductSearchService` (see `product-search.md`), which
handles free-text query search. `ProductService` is for browsing/lookup without a search term.
---
@@ -14,7 +14,7 @@ handles free-text query search. `ProductService` is for browsing/lookup without
Every method here reads Meilisearch documents directly and returns plain arrays — never Scout's
`->get()`, which would re-hydrate Eloquent models from the database. This means the index has to
carry everything a detail page needs (variants, prices, options, media, reviews — see below), not
just the trimmed fields a listing page needs. `Modules\Core\Search\ProductIndexer` is built to
just the trimmed fields a listing page needs. `Modules\Core\Product\Services\ProductIndexer` is built to
carry that full shape.
---
@@ -22,26 +22,34 @@ carry that full shape.
## Usage
```php
use Modules\Core\Catalog\ProductFilters;
use Modules\Core\Catalog\ProductService;
use Modules\Core\Product\DTOs\ProductFilters;
use Modules\Core\Product\Services\ProductService;
use Modules\Core\Product\Enums\ProductSort;
$service = app(ProductService::class);
// List everything, paginated
$result = $service->list(perPage: 24, page: 1);
// List everything, paginated — returns a real Illuminate\Pagination\LengthAwarePaginator,
// built from the localized Meilisearch hits (not Scout's own paginateRaw() result — see
// "Meilisearch driver quirk" below), so it behaves like any other Laravel paginator.
$products = $service->list(perPage: 24, page: 1);
// Filter by collection, brand, and/or price range
$result = $service->list(
$products = $service->list(
filters: new ProductFilters(collectionId: 17, minPrice: 10.0, maxPrice: 50.0),
perPage: 24,
page: 1,
);
$result['data']; // array of Meilisearch documents (plain arrays, not models)
$result['meta']['total'];
$result['meta']['per_page'];
$result['meta']['current_page'];
$result['meta']['last_page'];
// Sort — cheapest/priciest first, or newest first. Omit for Meilisearch's default
// relevance ordering (irrelevant here since the query is always empty).
$products = $service->list(perPage: 24, page: 1, sort: ProductSort::PriceAsc);
$products->items(); // array of Meilisearch documents (plain arrays, not models)
$products->total();
$products->perPage();
$products->currentPage();
$products->lastPage();
$products->links(); // in a Blade view — renders pagination links as usual
// Single product, by primary key
$product = $service->getById(367); // array, or null if not found
@@ -54,29 +62,27 @@ All `ProductFilters` fields are optional; only the ones set are added to the Mei
---
## Fields this depends on: `Modules\Core\Search\ProductIndexer`
## Fields this depends on: `Modules\Core\Product\Services\ProductIndexer`
Lunar's own `Lunar\Search\ProductIndexer` only carries listing-grade fields (name, description,
status, brand, a single thumbnail, skus) and marks just `__soft_deleted`, `skus`, `status` as
filterable. `Modules\Core\Search\ProductIndexer` extends it to add everything `ProductService`
filterable. `Modules\Core\Product\Services\ProductIndexer` extends it to add everything `ProductService`
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. |
`description` and other translated attributes are indexed as-is, including any HTML markup
(e.g. from a Shopify `Body (HTML)` import) — **not stripped**. Any view rendering a description
sourced from `ProductService`'s results must treat it as trusted HTML.
`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.
**`ProductOption`/`ProductOptionValue` names need a different translation accessor.** Unlike
`Product`/`Collection`/`Brand`, their `name` is a plain locale-keyed array cast, not
@@ -85,13 +91,41 @@ indexer's `translatedName()` reads the array directly instead. See `docs/lunar.m
---
## Locale resolution: `name`, `description`, and any other translated attribute
Lunar's base `ScoutIndexer` explodes every `TranslatedText` attribute into one `{handle}_{locale}`
field per store language at index time (`name_el`, `name_en`, `description_el`, ... — and the same
for any custom translated attribute a store adds, e.g. `seo_title`/`seo_description`). Every raw
document in Meilisearch carries all of them side by side, since a document is written once but
read across many different-locale requests.
`ProductService` resolves these back down to a single value per request. For every result it
returns (`list()`'s items, `getById()`, `getBySlug()`), it:
1. Reads which `Product` attributes are `TranslatedText` from `Lunar\Base\AttributeManifest` — the
same source Lunar's own indexer reads — rather than a hardcoded `['name', 'description']` list,
so a store's own custom translated attributes are picked up automatically with no change here.
2. For each one, resolves `{handle}_{currentLocale}`, falling back to `{handle}_{storeDefaultLocale}`
(`LanguageCache::defaultLocale()`) if the current locale has no translation — e.g. a product with
no English copy yet still shows its Greek name on `/en/` rather than rendering blank.
3. Assigns the result to a plain `{handle}` key and **strips every raw `{handle}_{locale}` key** —
callers only ever see `$product['name']`/`$product['seo_title']`/etc., never the per-locale
fields the index actually stores.
`description` and other translated attributes are otherwise indexed as-is, including any HTML
markup (e.g. from a Shopify `Body (HTML)` import) — **not stripped**. Any view rendering a
description sourced from `ProductService`'s results must treat it as trusted HTML.
---
## 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.
@@ -113,13 +147,28 @@ variants don't.
---
## Sorting
`ProductSort` (`Modules\Core\Product\Enums\ProductSort`) is a fixed enum of supported sort orders —
`PriceAsc`, `PriceDesc`, `Newest` — each mapping to a Meilisearch `sort` clause against a field
`Modules\Core\Product\Services\ProductIndexer::getSortableFields()` marks sortable (`price`, plus
`created_at`/`updated_at`/`skus`/`status` inherited from Lunar's base indexer). Adding a new
`ProductSort` case requires adding the matching field to `getSortableFields()` and re-syncing (see
below) — sortable attributes are index settings, not computed per-query, same as filterable ones.
Omitting `sort` leaves Meilisearch's default ordering, which is meaningless here since `list()`
always searches with an empty query string (`Product::search('')`) — there's no relevance score to
rank by, so results come back in whatever order the index returns them absent an explicit sort.
---
## Registering the indexer
Not automatic — an app opts in via its own `config/lunar/search.php`:
```php
'indexers' => [
Lunar\Models\Product::class => Modules\Core\Search\ProductIndexer::class,
Lunar\Models\Product::class => Modules\Core\Product\Services\ProductIndexer::class,
// ...other model indexers unchanged
],
```
+113
View File
@@ -0,0 +1,113 @@
# Product Option Types
Lunar's `ProductOption`/`ProductOptionValue` are generic by design — a "Color" option
and a "Size" option are both just a handle, a translated name, and a list of values.
Each `ProductOptionValue` carries a free-form `meta` jsonb column, but nothing in
Lunar's own admin UI exposes it — there's no way for an admin to, say, attach a hex
code to a "Red" value without editing the database directly.
`Modules\Core\Product\Contracts\ProductOptionTypeInterface` describes how a category
of option behaves — what structured data its values carry in `meta`, and how an
admin edits that data — without introducing a new model. `ProductOption`/
`ProductOptionValue` stay exactly as Lunar defines them.
---
## Registering a type
A shop registers a type class from its own service provider's `boot()`, the same
shape as `Modules\Core\Notification\NotificationRegistry`:
```php
use Modules\Core\Product\Services\ProductOptionTypeManager;
ProductOptionTypeManager::get()->register([
\App\ProductOptions\ColorOptionType::class,
]);
```
Not a published config array — the mapping isn't per-`ProductOption`, so there's
nothing for a shop to *key* by. Instead, an admin picks a type per-option from a
dropdown on the `ProductOption` edit form itself (see below); the choice is stored
in `ProductOption::meta['option_type']`, deliberately **not** tied to the option's
`handle` (a shop's own handle naming — transliterated Greek, legacy import slugs —
shouldn't have to match a type's key).
A `ProductOption` with no type selected behaves exactly as stock Lunar does — plain
name/position, no extra meta form.
---
## Writing a type
```php
namespace App\ProductOptions;
use Filament\Forms\Components\ColorPicker;
use Modules\Core\Product\Contracts\ProductOptionTypeInterface;
class ColorOptionType implements ProductOptionTypeInterface
{
public static function getKey(): string
{
return 'color';
}
public function getMetaForm(): array
{
return [
ColorPicker::make('meta.hex')
->label('Color')
->required(),
];
}
}
```
`getMetaForm()` returns Filament form components, keyed under `meta.*` dot notation
— the path they save to on `ProductOptionValue::meta` (cast as `AsArrayObject`, a
plain jsonb column). `getKey()` is the identifier used in the admin's "Option Type"
dropdown and in `ProductOption::meta['option_type']` — it has no relationship to the
`ProductOption::handle`.
A reference implementation ships at `Modules\Core\Product\OptionTypes\ColorOptionType`,
registered automatically by `Modules\Core\Providers\ProductServiceProvider` — no shop
setup needed for it to appear in the "Option Type" dropdown, though an admin still
has to pick it per-`ProductOption` for it to take effect.
---
## How it's wired into the admin UI
`Modules\Core\Product\Services\ProductOptionTypeManager` is a singleton registry:
- `get(): static` — the shared instance.
- `register(array $types): void` — registers one or more type classes, keyed
internally by `getKey()`.
- `unregister(string $key): void`
- `resolve(?string $key): ?ProductOptionTypeInterface` — looks up a registered type
by key (or `null` if no key / not found).
- `all(): array<string, class-string>` — every registered type's class, keyed by
`getKey()`.
Two extensions hook into Lunar's admin via its extension system
(`LunarPanel::extensions([...])`, registered in `CorePlugin`) — no forking of Lunar's
classes needed:
- `Modules\Core\Product\Filament\Extensions\ProductOptionResourceExtension` extends
`Lunar\Admin\Filament\Resources\ProductOptionResource`'s own form with a `Select`
(`meta.option_type`) listing every enabled type's key. Shown only when at least one
type is enabled.
- `Modules\Core\Product\Filament\Extensions\ValuesRelationManagerExtension` extends
the "Values" tab's form. Its `extendForm()` reads
`$option->meta['option_type']` off the owning `ProductOption`, resolves it via
`ProductOptionTypeManager`, and appends `getMetaForm()`'s fields to the stock name
field. A `ProductOption` with no type selected gets the stock form unchanged.
---
## Reading the value back
Storefront code reads `ProductOptionValue::meta` like any other jsonb column — e.g.
`$value->meta['hex']` for a color swatch. `ProductOptionTypeManager` is an admin-side
concern only (describing *how to edit* the meta); nothing requires the storefront to
go through it to *read* the meta.
+3 -3
View File
@@ -1,6 +1,6 @@
# Product Search
`Modules\Core\Search\ProductSearchService` provides locale-aware full-text product search on
`Modules\Core\Product\Services\ProductSearchService` provides locale-aware full-text product search on
top of Laravel Scout + Meilisearch.
---
@@ -24,7 +24,7 @@ merges `$builder->options` directly into the search request).
## Usage
```php
use Modules\Core\Search\ProductSearchService;
use Modules\Core\Product\Services\ProductSearchService;
$results = app(ProductSearchService::class)->search('running shoes');
// or an explicit locale, bypassing App::getLocale():
@@ -36,7 +36,7 @@ Returns an `Illuminate\Database\Eloquent\Collection` of `Lunar\Models\Product`
(`variants`, `brand`, `media`, etc.) are available on the results as normal.
`$locale` defaults to `App::getLocale()` — already set correctly on every storefront request by
`Modules\Core\Localization\LocaleMiddleware` (see `localization.md`), so callers in controllers
`Modules\Core\Localization\Middleware\LocaleMiddleware` (see `localization.md`), so callers in controllers
don't need to pass it explicitly.
---
-25
View File
@@ -1,25 +0,0 @@
<?php
namespace Modules\Core\Auth\Events;
use Illuminate\Contracts\Auth\Authenticatable;
use Lunar\Base\LunarUser;
/**
* Dispatched by UserOtpService::validate() on every successful OTP login, not just
* a first-time one. Modules\Core\Privacy listens on this to auto-cancel a pending
* DataErasureRequest — logging back in during the grace period is the "I changed
* my mind" action (see Modules\Core\Privacy\Listeners\CancelErasureOnLoginListener),
* which needs $user->customers to resolve any pending request. Typed as
* Authenticatable&LunarUser rather than plain Authenticatable (unlike the sibling
* UserCreated event) specifically because that listener depends on it — every real
* User in this codebase implements LunarUser (see docs/lunar.md "LunarUser trait"),
* and User is the only Authenticatable entity in this project (Customer is not —
* see docs/modules.md "Customer/User Pairing").
*/
class UserAuthenticated
{
public function __construct(
public readonly Authenticatable&LunarUser $user,
) {}
}
-4
View File
@@ -2,9 +2,7 @@
namespace Modules\Core\Auth\Services;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Mail;
use Modules\Core\Auth\Events\UserAuthenticated;
use Modules\Core\Auth\Mail\UserOtpMail;
class UserOtpService
@@ -45,8 +43,6 @@ class UserOtpService
$user->otp_expires_at = null;
$user->save();
Event::dispatch(new UserAuthenticated($user));
return $user;
}
}
-20
View File
@@ -1,20 +0,0 @@
<?php
namespace Modules\Core\Catalog;
/**
* Filter input for ProductService::list(). All fields are optional — omitted
* filters are simply not added to the Meilisearch query. Values are matched
* against Modules\Core\Search\ProductIndexer's document fields, so filtering
* only works on stores where that indexer is registered and the index has
* been re-synced (see docs/product-listing.md).
*/
class ProductFilters
{
public function __construct(
public readonly ?int $collectionId = null,
public readonly ?string $brand = null,
public readonly ?float $minPrice = null,
public readonly ?float $maxPrice = null,
) {}
}
-126
View File
@@ -1,126 +0,0 @@
<?php
namespace Modules\Core\Catalog;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\App;
use Lunar\Models\Product;
use Modules\Core\Localization\LocaleMiddleware;
/**
* Storefront product listing/filtering AND single-product lookup, all reading directly
* from the Meilisearch index (Modules\Core\Search\ProductIndexer) - one data source, no
* ->get() model hydration anywhere in this service. Callers get plain arrays of the
* indexed document, not Eloquent models.
*
* Full-text query search lives separately in Modules\Core\Search\ProductSearchService;
* this service is for browsing/filtering without a search term.
*/
class ProductService
{
/**
* @return array{data: array<int, array>, meta: array}
*/
public function list(?ProductFilters $filters = null, int $perPage = 24, int $page = 1): array
{
$paginator = Product::search('')
->options([
'filter' => $this->buildFilter($filters),
])
->paginateRaw(perPage: $perPage, page: $page);
return [
'data' => collect($this->hitsFrom($paginator))
->map(fn (array $product) => $this->withLocalizedFields($product))
->all(),
'meta' => [
'total' => $paginator->total(),
'per_page' => $paginator->perPage(),
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
],
];
}
/**
* Look up a single product by its URL slug (any locale - slugs are indexed across
* all languages, see Modules\Core\Search\ProductIndexer). Returns the full indexed
* product document, or null if no product has that slug.
*/
public function getBySlug(string $slug): ?array
{
return $this->findOneWhere('slugs = "'.addcslashes($slug, '"\\').'"');
}
/**
* Look up a single product by its primary key. Returns the full indexed product
* document, or null if no product has that id.
*/
public function getById(int $id): ?array
{
return $this->findOneWhere("id = \"{$id}\"");
}
private function findOneWhere(string $filter): ?array
{
$paginator = Product::search('')
->options(['filter' => $filter])
->paginateRaw(perPage: 1, page: 1);
$product = $this->hitsFrom($paginator)[0] ?? null;
return $product !== null ? $this->withLocalizedFields($product) : null;
}
/**
* Resolves the current-locale `name`/`description` from the indexer's
* per-locale `name_{locale}`/`description_{locale}` fields, falling back to
* the store's default language (Language::default, see
* LocaleMiddleware::defaultLocale()) when the current locale has no
* translation - e.g. a product with no English copy yet still shows its
* Greek name/description on /en/ rather than rendering blank.
*
* Deliberately not config('app.locale') - App::setLocale() overwrites that
* config value on every request, so by request time it's just whatever the
* current locale already is, not a stable fallback.
*/
private function withLocalizedFields(array $product): array
{
$locale = App::getLocale();
$fallbackLocale = LocaleMiddleware::defaultLocale();
$product['name'] = $product['name_'.$locale] ?? $product['name_'.$fallbackLocale] ?? null;
$product['description'] = $product['description_'.$locale] ?? $product['description_'.$fallbackLocale] ?? null;
return $product;
}
/**
* For the Meilisearch driver, Scout's paginateRaw() puts the whole raw response
* (hits, query, processingTimeMs, ...) in items(), not a plain list of hits - the
* actual documents are under the 'hits' key.
*/
private function hitsFrom(LengthAwarePaginator $paginator): array
{
$rawResponse = $paginator->items();
return collect($rawResponse['hits'] ?? [])->values()->all();
}
private function buildFilter(?ProductFilters $filters): ?string
{
if ($filters === null) {
return null;
}
$clauses = Collection::make([
$filters->collectionId !== null ? "collections = \"{$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,
])->filter();
return $clauses->isEmpty() ? null : $clauses->join(' AND ');
}
}
@@ -1,47 +0,0 @@
<?php
namespace Modules\Core\Command;
use Illuminate\Console\Command;
use Modules\Core\Privacy\ErasureRequestStatus;
use Modules\Core\Privacy\Jobs\EraseDataSubjectJob;
use Modules\Core\Privacy\Models\DataErasureRequest;
/**
* Finds every erasure request whose grace period (config('core.privacy.
* grace_period_days')) has passed and dispatches one EraseDataSubjectJob per
* request — see docs/privacy.md. This command itself just finds due requests and
* dispatches; the actual erasure work happens in the queue, one job per request,
* so one failing request doesn't block the others. Meant to run daily via the
* scheduler; each consuming app wires that in its own Console\Kernel (or
* bootstrap/app.php schedule closure on Laravel 11+), the same way it owns any
* other scheduled task — this package doesn't register schedules itself.
*/
class ProcessErasureRequestsCommand extends Command
{
protected $signature = 'boboko:privacy:process-erasure-requests';
protected $description = 'Dispatch an erasure job for every pending data-erasure request whose grace period has passed';
public function handle(): void
{
$due = DataErasureRequest::where('status', ErasureRequestStatus::Pending)
->where('scheduled_for', '<=', now())
->get();
if ($due->isEmpty()) {
$this->info('No due erasure requests.');
return;
}
foreach ($due as $request) {
EraseDataSubjectJob::dispatch($request);
$scope = $request->isForCustomer() ? 'customer' : 'user';
$this->info("Dispatched erasure job for {$scope} #{$request->subject_id} (request #{$request->id})");
}
$this->info('Dispatched '.$due->count().' erasure job(s).');
}
}
+6
View File
@@ -6,6 +6,8 @@ use Filament\Contracts\Plugin;
use Filament\Panel;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\Mail;
use Lunar\Admin\Filament\Resources\ProductOptionResource;
use Lunar\Admin\Filament\Resources\ProductOptionResource\RelationManagers\ValuesRelationManager;
use Lunar\Admin\Filament\Resources\ProductResource;
use Lunar\Admin\Filament\Resources\StaffResource;
use Lunar\Admin\Models\Staff as LunarStaff;
@@ -16,6 +18,8 @@ use Modules\Core\Auth\Extensions\StaffResourceExtension;
use Modules\Core\Auth\Filament\Pages\Login;
use Modules\Core\Auth\Mail\InviteMail;
use Modules\Core\Localization\Filament\Resources\LanguageLineResource;
use Modules\Core\Product\Filament\Extensions\ProductOptionResourceExtension;
use Modules\Core\Product\Filament\Extensions\ValuesRelationManagerExtension;
use Modules\Core\Review\Extensions\ProductResourceExtension;
use Modules\Core\Review\Models\ProductReview;
@@ -41,6 +45,8 @@ class CorePlugin implements Plugin
LunarPanel::extensions([
StaffResource::class => StaffResourceExtension::class,
ProductResource::class => ProductResourceExtension::class,
ProductOptionResource::class => ProductOptionResourceExtension::class,
ValuesRelationManager::class => ValuesRelationManagerExtension::class,
]);
Product::macro('reviews', function (): HasMany {
-21
View File
@@ -1,21 +0,0 @@
<?php
namespace Modules\Core\Export;
/**
* One column in a CsvWriter schema: a header label plus a closure that pulls this
* column's value out of one record. The closure doesn't care what shape a record
* is — an array, an Eloquent model, a DTO — so the same CsvWriter serves any
* domain (GDPR export, an admin catalog export, an accounting export) by simply
* being handed a different column schema and a different row source.
*/
final class CsvColumn
{
/**
* @param \Closure(mixed): (string|int|float|null) $value
*/
public function __construct(
public readonly string $header,
public readonly \Closure $value,
) {}
}
-45
View File
@@ -1,45 +0,0 @@
<?php
namespace Modules\Core\Export;
/**
* A generic columns + rows -> CSV file writer. No knowledge of any domain (GDPR,
* catalog, accounting, ...) — a caller supplies the schema (CsvColumn[]) and the
* data source (any iterable of records), and this writes one CSV. Reusable for
* any future bulk-export need without modification.
*/
class CsvWriter
{
/**
* @param array<int, CsvColumn> $columns
* @param iterable<mixed> $rows
*/
public function write(array $columns, iterable $rows, string $path): void
{
$handle = fopen($path, 'w');
fputcsv($handle, array_map(fn (CsvColumn $column) => $column->header, $columns));
foreach ($rows as $row) {
fputcsv($handle, array_map(
fn (CsvColumn $column) => $this->stringify(($column->value)($row)),
$columns
));
}
fclose($handle);
}
private function stringify(mixed $value): string
{
if ($value === null) {
return '';
}
if (is_array($value)) {
return json_encode($value);
}
return (string) $value;
}
}
@@ -5,7 +5,7 @@ namespace Modules\Core\Localization\Filament\Resources\LanguageLineResource\Page
use Filament\Resources\Pages\CreateRecord;
use Illuminate\Database\Eloquent\Model;
use Modules\Core\Localization\Filament\Resources\LanguageLineResource;
use Modules\Core\Localization\TranslationService;
use Modules\Core\Localization\Services\TranslationService;
class CreateLanguageLine extends CreateRecord
{
@@ -7,7 +7,7 @@ use Filament\Actions\Action;
use Filament\Resources\Pages\EditRecord;
use Illuminate\Database\Eloquent\Model;
use Modules\Core\Localization\Filament\Resources\LanguageLineResource;
use Modules\Core\Localization\TranslationService;
use Modules\Core\Localization\Services\TranslationService;
use Spatie\TranslationLoader\LanguageLine;
class EditLanguageLine extends EditRecord
@@ -5,12 +5,14 @@ namespace Modules\Core\Localization\Listeners;
use Modules\Core\Localization\Events\LanguageCreated;
use Modules\Core\Localization\Events\LanguageDeleted;
use Modules\Core\Localization\Events\LanguageUpdated;
use Modules\Core\Localization\LocaleMiddleware;
use Modules\Core\Localization\Services\LanguageCache;
class FlushLanguageCache
{
public function __construct(private readonly LanguageCache $languages) {}
public function handle(LanguageCreated|LanguageUpdated|LanguageDeleted $event): void
{
LocaleMiddleware::forgetLanguagesCache();
$this->languages->forget();
}
}
@@ -1,24 +1,24 @@
<?php
namespace Modules\Core\Localization;
namespace Modules\Core\Localization\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Facades\View;
use Lunar\Models\Language;
use Modules\Core\Localization\Services\LanguageCache;
use Symfony\Component\HttpFoundation\Response;
class LocaleMiddleware
{
private const CACHE_KEY = 'core.localization.languages';
public function __construct(private readonly LanguageCache $languages) {}
public function handle(Request $request, Closure $next): Response
{
$languages = $this->availableLanguages();
$languages = $this->languages->all();
if ($languages->isEmpty()) {
return $next($request);
@@ -45,41 +45,37 @@ class LocaleMiddleware
return $next($request);
}
public static function forgetLanguagesCache(): void
{
Cache::forget(self::CACHE_KEY);
}
/**
* The store's default language code (e.g. 'el') - the fixed fallback other
* locale-aware code (Modules\Core\Catalog\ProductService) should use, as
* opposed to config('app.locale') which App::setLocale() mutates per
* request and so can't serve as a stable fallback.
*/
public static function defaultLocale(): ?string
{
return (new self)->availableLanguages()->firstWhere('default', true)?->code;
}
/**
* Shares the current/alternate locale (and the alternate's URL) with all
* views, so the header language switcher and layout hreflang tags don't
* have to recompute it.
* Shares the current locale and every OTHER available locale (each with its
* own URL for the current page) with all views, so the header language
* switcher and layout hreflang tags don't have to recompute it.
*
* `altLocales` is a collection, not a single value — firstWhere('code', '!=',
* ...) would only ever surface one alternate, which happens to look correct
* with exactly 2 configured languages (there's only one "other" to find) but
* silently drops every locale past the first for a 3+ language store, with no
* error, just fewer switcher options than actually configured. A view iterates
* `$altLocales` to render as many links/dropdown entries as there are
* alternates, whether that's 1 or 10.
*/
private function shareLocaleViewData(Request $request, Language $language, Collection $languages): void
{
$altLanguage = $languages->firstWhere('code', '!=', $language->code);
$route = $request->route();
$routeName = $route?->getName();
$altLocales = $languages
->reject(fn (Language $other) => $other->code === $language->code)
->map(fn (Language $other) => [
'code' => $other->code,
'name' => $other->name,
'url' => $routeName
? route($routeName, array_merge($route->parameters(), ['locale' => $other->code]))
: url('/'.$other->code),
])
->values();
View::share('currentLocale', $language->code);
View::share('altLocale', $altLanguage?->code);
View::share(
'altLocaleUrl',
$altLanguage && $routeName
? route($routeName, array_merge($route->parameters(), ['locale' => $altLanguage->code]))
: ($altLanguage ? url('/'.$altLanguage->code) : null),
);
View::share('altLocales', $altLocales);
}
private function redirectToLocalizedUrl(Request $request, Collection $languages): Response
@@ -108,12 +104,4 @@ class LocaleMiddleware
return $languages->firstWhere('default', true)?->code
?? $languages->first()->code;
}
private function availableLanguages(): Collection
{
return Cache::rememberForever(
self::CACHE_KEY,
fn () => Language::query()->get(['id', 'code', 'name', 'default']),
);
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace Modules\Core\Localization\Models;
use Modules\Core\Localization\Services\LanguageCache;
use Spatie\TranslationLoader\LanguageLine as BaseLanguageLine;
/**
* Overrides the base package's locale fallback (config('app.fallback_locale'), a
* static .env value) with the store's actual default language — Lunar's
* `languages.default` flag, the same source LocaleMiddleware/LanguageCache already
* treat as the single source of truth for "this store's default language".
*
* Without this, changing the default language via the Filament Languages resource
* has no effect on which locale an untranslated storefront label falls back to —
* two disconnected "default locale" concepts silently drifting apart. Swapped in
* via config('translation-loader.model') (see LocalizationServiceProvider), the
* package's own documented extension point for this.
*/
class LanguageLine extends BaseLanguageLine
{
public function getTranslation(string $locale): ?string
{
if (isset($this->text[$locale])) {
return $this->text[$locale];
}
$fallback = app(LanguageCache::class)->defaultLocale();
return $fallback !== null ? ($this->text[$fallback] ?? null) : null;
}
}
@@ -1,6 +1,6 @@
<?php
namespace Modules\Core\Localization;
namespace Modules\Core\Localization\Observers;
use Illuminate\Support\Facades\Event;
use Lunar\Models\Language;
@@ -0,0 +1,58 @@
<?php
namespace Modules\Core\Localization\Services;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Lunar\Models\Language;
/**
* Cached read layer over Lunar's `languages` table — the single source both
* Modules\Core\Localization\Middleware\LocaleMiddleware (request-time locale resolution) and
* any other locale-aware code (e.g. Modules\Core\Product\Services\ProductService) read
* from, so the language list is fetched once per cache lifetime rather than once
* per caller. Cached forever, invalidated via forget() by
* Modules\Core\Localization\Listeners\FlushLanguageCache on
* LanguageCreated/LanguageUpdated/LanguageDeleted.
*/
class LanguageCache
{
private const CACHE_KEY = 'core.localization.languages';
public function all(): Collection
{
return Cache::rememberForever(
self::CACHE_KEY,
fn () => Language::query()->get(['id', 'code', 'name', 'default']),
);
}
/**
* The store's default language code (e.g. 'el') - the fixed fallback other
* locale-aware code should use, as opposed to config('app.locale') which
* App::setLocale() mutates per request and so can't serve as a stable
* fallback.
*/
public function defaultLocale(): ?string
{
return $this->all()->firstWhere('default', true)?->code;
}
/**
* Every configured store locale code (e.g. ['el', 'en']) - for code that needs
* to enumerate all locales a TranslatedText attribute was indexed under (see
* Modules\Core\Product\Services\ProductService::withLocalizedFields()), rather than
* hardcoding locale codes.
*
* @return array<int, string>
*/
public function availableLocales(): array
{
return $this->all()->pluck('code')->all();
}
public function forget(): void
{
Cache::forget(self::CACHE_KEY);
}
}
@@ -1,6 +1,6 @@
<?php
namespace Modules\Core\Localization;
namespace Modules\Core\Localization\Services;
use Illuminate\Support\Facades\App;
use Spatie\TranslationLoader\LanguageLine;
@@ -1,6 +1,6 @@
<?php
namespace Modules\Core\Localization;
namespace Modules\Core\Localization\Services;
use Illuminate\Support\Facades\Event;
use Modules\Core\Localization\Events\TranslationCreated;
@@ -7,13 +7,23 @@ use Lunar\Models\Url;
class ProductResolver
{
/**
* A slug can have more than one `lunar_urls` row pointing at it across import
* batches — e.g. a product soft-deleted and re-imported leaves its old URL row
* behind, still matching the same slug. Picking "whichever Url row matches
* first" (as a plain Url::where('slug', ...)->first() would) can resolve to a
* soft-deleted product, silently failing every downstream write for that
* product (e.g. JudgeMeExportImporter logging "no product found" for a handle
* that, in isolation, clearly exists). Join against `lunar_products` directly
* so only a URL pointing at a live (non-deleted) product resolves.
*/
public function resolve(string $handle): ?Product
{
$url = Url::query()
->where('slug', $handle)
->where('element_type', (new Product)->getMorphClass())
return Product::query()
->join('lunar_urls', 'lunar_urls.element_id', '=', 'lunar_products.id')
->where('lunar_urls.slug', $handle)
->where('lunar_urls.element_type', (new Product)->getMorphClass())
->select('lunar_products.*')
->first();
return $url?->element;
}
}
@@ -1,55 +0,0 @@
<?php
namespace Modules\Core\Privacy\Contracts;
use Modules\Core\Privacy\CustomerSubject;
use Modules\Core\Privacy\ProviderErasureResult;
use Modules\Core\Privacy\ProviderExportResult;
use Modules\Core\Privacy\UserSubject;
/**
* Implemented by any module that holds personal data and wants it included in
* right-of-access/right-of-erasure requests — core, or a future ERP/banking/etc.
* module. Core has no knowledge of what a provider actually stores or how; it only
* calls these four methods and collects the results (see PrivacyManager).
*
* Two independent scopes, not one — see docs/privacy.md "User-scope vs
* Customer-scope". A Customer (business account, per Lunar's model) can have many
* linked Users, and one User can be linked to many Customer accounts (B2B
* multi-seat access — see docs/modules.md "Customer/User Pairing"), so "erase this
* person's identity" and "erase this business account's data" are genuinely
* different operations with different blast radii:
* - *ForUser(): erase/export one individual — their login, name, email —
* wherever it appears, without touching any Customer account's own data
* (orders, addresses) or any other User linked to those accounts.
* - *ForCustomer(): erase/export one business account's own data, without
* touching any linked User's login or personal identity.
* A provider with nothing relevant to one scope implements that method as a
* no-op returning ErasureOutcome::Skipped (for erase) or an empty payload (for
* export) — see e.g. AddressDataProvider::eraseForUser().
*
* A provider owns its own retention judgment. There's no central taxonomy of "PII
* vs financial data" in this contract on purpose — only the module that owns a
* given table actually knows whether its data is freely erasable, must be
* pseudonymized (e.g. financial records under a legal retention requirement), or
* must be retained outright (e.g. fraud/security records). erase*() expresses
* that by returning a ProviderErasureResult with the outcome that actually
* happened.
*/
interface PersonalDataProvider
{
/**
* A short, stable, unique machine name for this provider (e.g. 'customer',
* 'orders', 'reviews') — used as the export payload's top-level key and in
* erasure reports. Must not collide with another registered provider's name.
*/
public function name(): string;
public function exportForUser(UserSubject $subject): ProviderExportResult;
public function exportForCustomer(CustomerSubject $subject): ProviderExportResult;
public function eraseForUser(UserSubject $subject): ProviderErasureResult;
public function eraseForCustomer(CustomerSubject $subject): ProviderErasureResult;
}
-28
View File
@@ -1,28 +0,0 @@
<?php
namespace Modules\Core\Privacy;
use Lunar\Models\Customer;
/**
* Identifies "the business account" for a Customer-scoped data-subject request —
* erasing/exporting a Customer's own data (orders, addresses, the account record
* itself). Deliberately carries no userIds/email: Customer-scope must never touch
* any linked User's login or personal identity, only the account's own data — see
* docs/privacy.md "User-scope vs Customer-scope". A provider that needs to know
* which Users are linked (e.g. to export their names as account contacts, without
* erasing their logins) looks that up itself via the Customer model, rather than
* this value object handing it out — keeping "erase a Customer" structurally
* incapable of touching a User row is the whole point of the split.
*/
class CustomerSubject
{
public function __construct(
public readonly int $customerId,
) {}
public static function forCustomer(Customer $customer): self
{
return new self(customerId: $customer->id);
}
}
-16
View File
@@ -1,16 +0,0 @@
<?php
namespace Modules\Core\Privacy;
/**
* What actually happened to a provider's data on an erasure request. None of these
* are failures — Retained is a valid, often legally-required outcome (e.g. an Order
* kept intact for tax retention), distinct from a provider erroring out.
*/
enum ErasureOutcome: string
{
case Erased = 'erased';
case Pseudonymized = 'pseudonymized';
case Retained = 'retained';
case Skipped = 'skipped';
}
-31
View File
@@ -1,31 +0,0 @@
<?php
namespace Modules\Core\Privacy;
/**
* Every registered provider's outcome, assembled into one right-of-erasure response
* — the audit trail proving what happened and, for anything not fully erased, why.
* $subject is whichever scope the request was for — see docs/privacy.md
* "User-scope vs Customer-scope".
*/
class ErasureReport
{
/**
* @param array<int, ProviderErasureResult> $results
*/
public function __construct(
public readonly UserSubject|CustomerSubject $subject,
public readonly array $results,
) {}
/**
* @return array<int, ProviderErasureResult>
*/
public function retained(): array
{
return array_values(array_filter(
$this->results,
fn (ProviderErasureResult $result) => $result->outcome === ErasureOutcome::Retained
));
}
}
-10
View File
@@ -1,10 +0,0 @@
<?php
namespace Modules\Core\Privacy;
enum ErasureRequestStatus: string
{
case Pending = 'pending';
case Cancelled = 'cancelled';
case Completed = 'completed';
}
@@ -1,20 +0,0 @@
<?php
namespace Modules\Core\Privacy\Events;
use Modules\Core\Privacy\Models\DataExportRequest;
/**
* Fired once the export file exists and $request has been marked completed. Core
* has no opinion on how the customer should be told — a consuming app registers
* its own notification against this event via Modules\Core\Notification\
* NotificationRegistry, the same pattern as App\Notifications\
* QuestionnaireResultsSentNotification listening on App\Events\
* QuestionnaireResultsSent.
*/
class PersonalDataExportFileWritten
{
public function __construct(
public readonly DataExportRequest $request,
) {}
}
@@ -1,22 +0,0 @@
<?php
namespace Modules\Core\Privacy\Events;
use Modules\Core\Privacy\ExportReport;
use Modules\Core\Privacy\Models\DataExportRequest;
/**
* Fired once ExportDataSubjectJob has gathered every registered provider's data —
* no file exists yet at this point. Modules\Core\Privacy\Listeners\
* WriteExportToCsvListener (registered in PrivacyServiceProvider) is what actually
* turns this into a file, kept as its own listener rather than inline in the job
* so the export *format* (CSV today) is swappable without touching how the data
* is gathered.
*/
class PersonalDataGathered
{
public function __construct(
public readonly DataExportRequest $request,
public readonly ExportReport $report,
) {}
}
@@ -1,19 +0,0 @@
<?php
namespace Modules\Core\Privacy\Events;
use Modules\Core\Privacy\Models\DataErasureRequest;
/**
* Fired by PrivacyService::requestErasureForUser() right after the grace-period
* request is created (not at completeErasure() time — see
* Modules\Core\Privacy\Listeners\CascadeCustomerErasureListener, which needs to
* act while the User is still linked to their Customers, before any detach has
* happened).
*/
class UserErasureRequested
{
public function __construct(
public readonly DataErasureRequest $request,
) {}
}
-33
View File
@@ -1,33 +0,0 @@
<?php
namespace Modules\Core\Privacy;
/**
* Every registered provider's export, assembled into one right-of-access response.
* $subject is whichever scope the request was for — see docs/privacy.md
* "User-scope vs Customer-scope".
*/
class ExportReport
{
/**
* @param array<int, ProviderExportResult> $results
*/
public function __construct(
public readonly UserSubject|CustomerSubject $subject,
public readonly array $results,
) {}
/**
* @return array<string, array<string, mixed>> keyed by provider name
*/
public function toArray(): array
{
$data = [];
foreach ($this->results as $result) {
$data[$result->provider] = $result->data;
}
return $data;
}
}
-10
View File
@@ -1,10 +0,0 @@
<?php
namespace Modules\Core\Privacy;
enum ExportRequestStatus: string
{
case Pending = 'pending';
case Completed = 'completed';
case Failed = 'failed';
}
-36
View File
@@ -1,36 +0,0 @@
<?php
namespace Modules\Core\Privacy\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Modules\Core\Privacy\Models\DataErasureRequest;
use Modules\Core\Privacy\PrivacyService;
/**
* Runs PrivacyService::completeErasure() for one due DataErasureRequest, dispatched
* per-request by ProcessErasureRequestsCommand rather than looping over
* completeErasure() calls inline in the command. One job per request means one
* request's failure (a provider throwing, a DB error) doesn't block or crash
* processing of the others, and Laravel's normal per-job retry/failure handling
* applies to each request independently.
*/
class EraseDataSubjectJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public function __construct(
public readonly DataErasureRequest $request,
) {}
public function handle(PrivacyService $privacyService): void
{
$privacyService->completeErasure($this->request);
}
}
-67
View File
@@ -1,67 +0,0 @@
<?php
namespace Modules\Core\Privacy\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Event;
use Modules\Core\Privacy\CustomerSubject;
use Modules\Core\Privacy\Events\PersonalDataGathered;
use Modules\Core\Privacy\ExportReport;
use Modules\Core\Privacy\ExportRequestStatus;
use Modules\Core\Privacy\Models\DataExportRequest;
use Modules\Core\Privacy\PrivacyManager;
use Modules\Core\Privacy\UserSubject;
/**
* Gathers every registered PersonalDataProvider's export data for one request, all
* sequentially in this single job — deliberately not fanned out into one job per
* provider. Per-subject export work is small (a handful of indexed queries per
* provider), so there's no real parallelism win, and one job means "finished" is
* just "handle() returned," with no Bus::batch()/completion-counting needed. If a
* future provider ever does something genuinely slow (an external API call, a
* generated PDF), that's the point to reconsider — not before.
*
* Calls each provider's *ForCustomer() or *ForUser() method depending on the
* request's polymorphic subject — see Modules\Core\Privacy\PrivacyService and
* docs/privacy.md "User-scope vs Customer-scope".
*
* Writing the gathered data to a file is intentionally NOT done here — see
* PersonalDataGathered and Modules\Core\Privacy\Listeners\WriteExportToCsvListener,
* which keeps the export *format* swappable without touching how data is gathered.
*/
class ExportDataSubjectJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public function __construct(
public readonly DataExportRequest $request,
) {}
public function handle(PrivacyManager $manager): void
{
if ($this->request->isForCustomer()) {
$subject = new CustomerSubject(customerId: $this->request->subject_id);
$results = array_map(fn ($provider) => $provider->exportForCustomer($subject), $manager->providers());
} else {
$subject = new UserSubject(userId: $this->request->subject_id, email: $this->request->email);
$results = array_map(fn ($provider) => $provider->exportForUser($subject), $manager->providers());
}
Event::dispatch(new PersonalDataGathered(
$this->request,
new ExportReport($subject, $results)
));
}
public function failed(\Throwable $exception): void
{
$this->request->update(['status' => ExportRequestStatus::Failed]);
}
}
@@ -1,61 +0,0 @@
<?php
namespace Modules\Core\Privacy\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
use Modules\Core\Auth\Events\UserAuthenticated;
use Modules\Core\Privacy\ErasureRequestStatus;
use Modules\Core\Privacy\Models\DataErasureRequest;
use Modules\Core\Privacy\PrivacyService;
/**
* Logging back in during a pending erasure request's grace period IS the "I
* changed my mind" action (same pattern as Shopify's own account-deletion flow).
* Authentication itself is never blocked by deactivation — the OTP check in
* UserOtpService::validate() already passed by the time this fires — only what
* happens to the account afterward: any pending request is cancelled and the
* login block lifted (see PrivacyService::cancelErasure()).
*
* Only checks this User's own erasure request, not any Customer-scoped one
* directly — a Customer-scoped erasure never deactivates a User's login at all
* (see docs/privacy.md "User-scope vs Customer-scope"), so there is nothing for a
* login to reactivate on that side. Only a User-scoped request (keyed on this
* User's own id) can have deactivated this login in the first place.
*
* If cancelling that request undoes it, this also reverts every Customer
* erasure request it caused (via Modules\Core\Privacy\Listeners\
* CascadeCustomerErasureListener — see DataErasureRequest::caused()). Those are
* traced by caused_by_request_id specifically so only the cascade THIS User's
* own request triggered is reverted, never an unrelated, independently-requested
* Customer erasure the User happens to be linked to.
*
* Queued (ShouldQueue) — login should return to the browser quickly, without
* waiting on this bookkeeping. Nothing else in this codebase currently reads
* deactivated_at except this listener and PrivacyService itself (grep before
* assuming otherwise, if that ever changes) — UserOtpService::validate() never
* gates the login on it — so a brief window between the login response and this
* job actually running has no other consumer to observe it as stale.
*/
class CancelErasureOnLoginListener implements ShouldQueue
{
public function __construct(private readonly PrivacyService $privacyService) {}
public function handle(UserAuthenticated $event): void
{
$request = DataErasureRequest::where('subject_type', $event->user->getMorphClass())
->where('subject_id', $event->user->id)
->where('status', ErasureRequestStatus::Pending)
->latest()
->first();
if (! $request) {
return;
}
$this->privacyService->cancelErasure($request);
foreach ($request->caused as $causedRequest) {
$this->privacyService->cancelErasure($causedRequest);
}
}
}
@@ -1,64 +0,0 @@
<?php
namespace Modules\Core\Privacy\Listeners;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Lunar\Base\LunarUser;
use Lunar\Models\Customer;
use Modules\Core\Privacy\Events\UserErasureRequested;
use Modules\Core\Privacy\PrivacyService;
/**
* When a User's erasure leaves a Customer account with no remaining User at all,
* that Customer's PII (name, addresses, order history) becomes permanently
* unreachable through any login — GDPR data minimization (Art. 5(1)(c)) means it
* shouldn't just sit there. This listener checks every Customer the User is
* linked to: if this User is currently the SOLE user on that Customer (count ===
* 1 and that one user is this user — not just count === 1, in case of a
* stale/unexpected read), it also opens a grace-period Customer erasure request
* for that Customer, tagged via caused_by_request_id so
* CancelErasureOnLoginListener can revert exactly this cascade — and only this
* cascade — if the User logs back in and changes their mind.
*
* Queued (ShouldQueue), not synchronous — this runs as an independent,
* separately-retryable unit of work rather than inline inside
* PrivacyService::requestErasureForUser(), so a failure here never rolls back or
* blocks the User's own request. Because Eloquent models on a queued event are
* re-fetched fresh when the job actually runs (not a stale snapshot from dispatch
* time — see Illuminate\Queue\SerializesModels), $event->request->subject and its
* ->customers reflect the real, current state at execution time. That matters
* specifically for the immediate-erasure path (requestImmediateErasureForUser()):
* this job may run before or after completeErasure() detaches the User's
* memberships — if the detach happens first, ->customers is simply empty by the
* time this runs and nothing cascades, which is an accepted, understood race for
* that rare staff-triggered path (see docs/privacy.md). The everyday grace-period
* path (requestErasureForUser()) has no such race, since nothing detaches the
* User's memberships until its own later, separate completeErasure() run.
*
* Both requests then run through their own independent grace periods.
*/
class CascadeCustomerErasureListener implements ShouldQueue
{
public function __construct(private readonly PrivacyService $privacyService) {}
public function handle(UserErasureRequested $event): void
{
$user = $event->request->subject;
if (! $user) {
return;
}
foreach ($user->customers as $customer) {
if ($this->isSoleUser($customer, $user)) {
$this->privacyService->requestErasureForCustomer($customer, $user, causedByRequestId: $event->request->id);
}
}
}
private function isSoleUser(Customer $customer, Authenticatable&LunarUser $user): bool
{
return $customer->users->count() === 1 && $customer->users->first()->id === $user->id;
}
}
@@ -1,92 +0,0 @@
<?php
namespace Modules\Core\Privacy\Listeners;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Storage;
use Modules\Core\Export\CsvColumn;
use Modules\Core\Export\CsvWriter;
use Modules\Core\Privacy\Events\PersonalDataExportFileWritten;
use Modules\Core\Privacy\Events\PersonalDataGathered;
use Modules\Core\Privacy\ExportRequestStatus;
use ZipArchive;
/**
* Turns a PersonalDataGathered event's ExportReport into one CSV per
* provider, zipped together, using the generic Modules\Core\Export\CsvWriter — kept
* as its own listener (not inline in ExportDataSubjectJob) so the export *format*
* is swappable (e.g. an app could unregister this and register its own JSON-only
* listener) without touching how the data is gathered.
*
* Column schema: every provider's data is either a list of associative arrays
* (rows directly) or a single associative array (one row) — see the providers in
* Modules\Core\Privacy\Providers, all of which return exactly one of those two
* shapes. Any nested array value within a row (e.g. an order's `addresses`) is
* JSON-encoded into that one cell rather than exploded into further columns —
* CsvWriter's generic stringify() behavior, not special-cased here.
*/
class WriteExportToCsvListener
{
public function __construct(private readonly CsvWriter $writer) {}
public function handle(PersonalDataGathered $event): void
{
$disk = Storage::disk('local');
$exportDir = $disk->path('exports/privacy');
if (! is_dir($exportDir)) {
mkdir($exportDir, 0755, true);
}
$stamp = now()->format('Y_m_d_His');
$zipPath = "{$exportDir}/export_{$event->request->id}_{$stamp}.zip";
$zip = new ZipArchive;
$zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE);
foreach ($event->report->results as $result) {
$csvPath = "{$exportDir}/{$result->provider}_{$stamp}.csv";
$this->writer->write($this->columnsFor($result->data), $this->rowsFor($result->data), $csvPath);
$zip->addFile($csvPath, "{$result->provider}.csv");
}
$zip->close();
foreach ($event->report->results as $result) {
@unlink("{$exportDir}/{$result->provider}_{$stamp}.csv");
}
$event->request->update([
'status' => ExportRequestStatus::Completed,
'file_path' => $zipPath,
'completed_at' => now(),
]);
Event::dispatch(new PersonalDataExportFileWritten($event->request));
}
/**
* @return array<int, mixed>
*/
private function rowsFor(array $data): array
{
// A list of records (addresses, orders, reviews) -> those are the rows.
// A single associative record (customer) -> one row.
return array_is_list($data) ? $data : [$data];
}
/**
* @return array<int, CsvColumn>
*/
private function columnsFor(array $data): array
{
$sample = array_is_list($data) ? ($data[0] ?? []) : $data;
return array_map(
fn (string $key) => new CsvColumn($key, fn (array $row) => $row[$key] ?? null),
array_keys($sample)
);
}
}
-82
View File
@@ -1,82 +0,0 @@
<?php
namespace Modules\Core\Privacy\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Lunar\Models\Customer;
use Modules\Core\Privacy\ErasureRequestStatus;
/**
* A pending, cancelled, or completed right-of-erasure request — the grace-period
* record between "subject/staff asked for this" and "providers actually erased
* their data" (see Modules\Core\Privacy\PrivacyService, which creates/processes
* these).
*
* `subject` is polymorphic — either a Lunar Customer (business account) or a User
* (individual), never both. See docs/privacy.md "User-scope vs Customer-scope" for
* why these are two genuinely different operations with different blast radii,
* not one "erase this customer and cascade to their users" flow.
*
* `requestedBy` is separately polymorphic (the subject themselves, self-service,
* or Staff acting on their behalf), stored as plain type+id columns rather than
* morphs() since it's always exactly one of those two concrete actor types.
*/
class DataErasureRequest extends Model
{
protected $guarded = [];
protected $casts = [
'status' => ErasureRequestStatus::class,
'scheduled_for' => 'datetime',
'cancelled_at' => 'datetime',
'completed_at' => 'datetime',
'report' => 'array',
];
public function subject(): MorphTo
{
return $this->morphTo(__FUNCTION__, 'subject_type', 'subject_id');
}
public function requestedBy(): MorphTo
{
return $this->morphTo(__FUNCTION__, 'requested_by_type', 'requested_by_id');
}
/**
* The User erasure request that caused this one to be auto-created, if any —
* see Modules\Core\Privacy\Listeners\CascadeCustomerErasureListener.
*/
public function causedBy(): BelongsTo
{
return $this->belongsTo(self::class, 'caused_by_request_id');
}
/**
* Every Customer erasure request THIS request caused (see causedBy()) —
* used by CancelErasureOnLoginListener to revert exactly the cascade this
* User's own cancellation should undo.
*/
public function caused(): HasMany
{
return $this->hasMany(self::class, 'caused_by_request_id');
}
public function isForCustomer(): bool
{
return $this->subject_type === (new Customer)->getMorphClass();
}
public function isPending(): bool
{
return $this->status === ErasureRequestStatus::Pending;
}
public function isDue(): bool
{
return $this->isPending() && $this->scheduled_for->isPast();
}
}
-37
View File
@@ -1,37 +0,0 @@
<?php
namespace Modules\Core\Privacy\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Lunar\Models\Customer;
use Modules\Core\Privacy\ExportRequestStatus;
/**
* A right-of-access export request. Created synchronously (fast — one insert), then
* ExportDataSubjectJob (queued) does the actual work of gathering every registered
* provider's data and, via Modules\Core\Privacy\Listeners\WriteExportToCsvListener,
* writing it to a file. file_path is null until that completes.
*
* `subject` is polymorphic — either a Lunar Customer (business account) or a User
* (individual), never both. See docs/privacy.md "User-scope vs Customer-scope".
*/
class DataExportRequest extends Model
{
protected $guarded = [];
protected $casts = [
'status' => ExportRequestStatus::class,
'completed_at' => 'datetime',
];
public function subject(): MorphTo
{
return $this->morphTo(__FUNCTION__, 'subject_type', 'subject_id');
}
public function isForCustomer(): bool
{
return $this->subject_type === (new Customer)->getMorphClass();
}
}
-50
View File
@@ -1,50 +0,0 @@
<?php
namespace Modules\Core\Privacy;
use Illuminate\Contracts\Container\Container;
use Modules\Core\Privacy\Contracts\PersonalDataProvider;
/**
* The registry every PersonalDataProvider is collected through. A module registers
* by adding its provider's class name to config('core.privacy.providers') — the
* same shape as Lunar's own config('lunar.search.indexers') model->indexer map, just
* a plain list since a provider isn't keyed to one model. Core never references a
* specific provider class; a future ERP/banking/etc. module just adds its own
* provider class to that config array and PrivacyService picks it up automatically.
*/
class PrivacyManager
{
public function __construct(private readonly Container $container) {}
/**
* @return array<int, PersonalDataProvider>
*/
public function providers(): array
{
$providers = array_map(
fn (string $class) => $this->container->make($class),
config('core.privacy.providers', [])
);
$this->assertUniqueNames($providers);
return $providers;
}
/**
* @param array<int, PersonalDataProvider> $providers
*/
private function assertUniqueNames(array $providers): void
{
$names = array_map(fn (PersonalDataProvider $provider) => $provider->name(), $providers);
$duplicates = array_diff_assoc($names, array_unique($names));
if ($duplicates !== []) {
throw new \LogicException(
'Duplicate Modules\Core\Privacy provider name(s): '.implode(', ', array_unique($duplicates))
.'. Each provider registered in config(\'core.privacy.providers\') must return a unique name().'
);
}
}
}
-279
View File
@@ -1,279 +0,0 @@
<?php
namespace Modules\Core\Privacy;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Event;
use Lunar\Base\LunarUser;
use Lunar\Models\Customer;
use Modules\Core\Auth\Models\Staff;
use Modules\Core\Privacy\Events\UserErasureRequested;
use Modules\Core\Privacy\Jobs\ExportDataSubjectJob;
use Modules\Core\Privacy\Models\DataErasureRequest;
use Modules\Core\Privacy\Models\DataExportRequest;
/**
* Entry point for right-of-access and right-of-erasure requests, split into two
* independent scopes — see docs/privacy.md "User-scope vs Customer-scope":
*
* - *ForCustomer(): erases/exports one business account's own data (orders,
* addresses, the account record itself). Never touches any linked User's
* login or personal identity — a Customer erasure request must not deactivate
* or destroy access for anyone who works there.
* - *ForUser(): erases/exports one individual's own identity (login, name,
* email) wherever it appears, and detaches them from every Customer account
* they're linked to as part of erasure — without touching any Customer
* account's own data or any other User still linked to it.
*
* A Customer (business account) can have many linked Users, and one User can be
* linked to many Customer accounts (B2B multi-seat access — see docs/modules.md
* "Customer/User Pairing"), so these are genuinely different operations with
* different blast radii, not one flow with an optional cascade.
*
* Both directions are handled as requests, not immediate synchronous actions:
* requestExport*() queues the (potentially slow) work of gathering every
* provider's data and writing a file, rather than blocking whatever triggered it.
* requestErasure*() opens a cancellable grace-period request (deactivating the
* account for a User-scoped request only — see below) — the same shape as
* Shopify's own account-deletion flow: a window where the subject can change
* their mind before anything is actually erased.
*/
class PrivacyService
{
public function __construct(private readonly PrivacyManager $manager) {}
/**
* Creates a DataExportRequest (fast — one insert) and dispatches
* ExportDataSubjectJob to do the actual gathering/writing work. The job fires
* PersonalDataGathered once every provider's data is collected;
* Modules\Core\Privacy\Listeners\WriteExportToCsvListener turns that into a file
* and fires PersonalDataExportFileWritten — a consuming app registers its own
* notification against that event (see docs/privacy.md).
*/
public function requestExportForCustomer(Customer $customer): DataExportRequest
{
return $this->createExportRequest($customer->getMorphClass(), $customer->id, null);
}
public function requestExportForUser(Authenticatable&LunarUser $user): DataExportRequest
{
return $this->createExportRequest($user->getMorphClass(), $user->id, $user->email);
}
private function createExportRequest(string $subjectType, int $subjectId, ?string $email): DataExportRequest
{
$request = DataExportRequest::create([
'subject_type' => $subjectType,
'subject_id' => $subjectId,
'email' => $email,
'status' => ExportRequestStatus::Pending,
]);
ExportDataSubjectJob::dispatch($request);
return $request;
}
/**
* Opens a grace-period erasure request for the Customer's own data. Deactivates
* NO User — erasing a business account must not destroy anyone's login access,
* even the account's own primary contact. Nothing is actually erased until
* privacy:process-erasure-requests picks this up once scheduled_for has
* passed, unless cancelErasure() is called first.
*
* $requestedBy is the Customer themselves (self-service deletion), a Staff
* member acting on their behalf, or a User — the User case is for
* Modules\Core\Privacy\Listeners\CascadeCustomerErasureListener, where erasing
* a User leaves a Customer with no remaining user: the User is a real,
* meaningful "who caused this," even though they didn't directly request the
* Customer's own erasure. $causedByRequestId links a cascade-created request
* back to the User erasure request that triggered it, so
* CancelErasureOnLoginListener can revert exactly that cascade on login,
* without touching an unrelated, independently-requested Customer erasure.
*/
public function requestErasureForCustomer(
Customer $customer,
Customer|Staff|(Authenticatable&LunarUser) $requestedBy,
?int $causedByRequestId = null,
): DataErasureRequest {
return DataErasureRequest::create([
'subject_type' => $customer->getMorphClass(),
'subject_id' => $customer->id,
'email' => null,
'requested_by_type' => $requestedBy->getMorphClass(),
'requested_by_id' => $requestedBy->getKey(),
'status' => ErasureRequestStatus::Pending,
'scheduled_for' => now()->addDays(config('core.privacy.grace_period_days', 30)),
'caused_by_request_id' => $causedByRequestId,
]);
}
/**
* Opens a grace-period erasure request for one individual and deactivates
* their login immediately (blocks it — see Modules\Core\Auth\Services\
* UserOtpService — without touching any Customer account's data). $requestedBy
* is either the User themselves (self-service deletion) or a Staff member
* acting on their behalf.
*/
public function requestErasureForUser(Authenticatable&LunarUser $user, (Authenticatable&LunarUser)|Staff $requestedBy): DataErasureRequest
{
$request = DataErasureRequest::create([
'subject_type' => $user->getMorphClass(),
'subject_id' => $user->id,
'email' => $user->email,
'requested_by_type' => $requestedBy->getMorphClass(),
'requested_by_id' => $requestedBy->getKey(),
'status' => ErasureRequestStatus::Pending,
'scheduled_for' => now()->addDays(config('core.privacy.grace_period_days', 30)),
]);
$this->setUserDeactivated($user->id, true);
// CascadeCustomerErasureListener implements ShouldQueue, so this just
// enqueues a job rather than running inline — no transaction wrapping
// needed here, since the cascade check happens as an independent,
// separately-retryable unit of work after this request is already
// committed, not as part of this same call.
Event::dispatch(new UserErasureRequested($request));
return $request;
}
/**
* Erases a Customer's data right now, bypassing the grace period entirely.
* Staff-only by construction — $requestedBy is typed to Staff specifically,
* so a self-service/customer-facing code path cannot reach this method at
* all, only accidentally call it with the wrong actor type and get a
* compile-time error. This exists for a formal legal request or regulator
* inquiry that genuinely requires immediate action — not a convenience
* option for an impatient customer. The grace period is deliberately not
* skippable from any customer-facing flow; see docs/privacy.md.
*/
public function requestImmediateErasureForCustomer(Customer $customer, Staff $requestedBy): ErasureReport
{
$request = DataErasureRequest::create([
'subject_type' => $customer->getMorphClass(),
'subject_id' => $customer->id,
'email' => null,
'requested_by_type' => $requestedBy->getMorphClass(),
'requested_by_id' => $requestedBy->getKey(),
'status' => ErasureRequestStatus::Pending,
'scheduled_for' => now(),
]);
return $this->completeErasure($request);
}
/**
* Erases a User's data right now, bypassing the grace period entirely.
* Staff-only by construction — see requestImmediateErasureForCustomer().
*
* Still fires UserErasureRequested — and deliberately BEFORE completeErasure()
* runs, not after — so Modules\Core\Privacy\Listeners\
* CascadeCustomerErasureListener sees the User still linked to their Customers
* (completeErasure() -> CustomerDataProvider::eraseForUser() is what detaches
* the pivot). The User's own erasure is immediate, but any Customer left
* orphaned by it still gets a normal grace-period erasure request, not an
* immediate one — an orphaned Customer isn't itself the subject of the
* original urgent request.
*/
public function requestImmediateErasureForUser(Authenticatable&LunarUser $user, Staff $requestedBy): ErasureReport
{
$request = DataErasureRequest::create([
'subject_type' => $user->getMorphClass(),
'subject_id' => $user->id,
'email' => $user->email,
'requested_by_type' => $requestedBy->getMorphClass(),
'requested_by_id' => $requestedBy->getKey(),
'status' => ErasureRequestStatus::Pending,
'scheduled_for' => now(),
]);
$this->setUserDeactivated($user->id, true);
// Queued (see requestErasureForUser()) — the cascade job may run before
// or after completeErasure() below detaches the pivot. Either is fine:
// CascadeCustomerErasureListener re-reads $user->customers fresh when it
// runs, so it only cascades if this User is still linked at that point.
// If completeErasure() detaches first, the queued job simply finds no
// Customers left to check and no-ops — never a wrong cascade, at worst a
// missed one on a race that immediate (staff-triggered, rare) erasure
// doesn't need to guard against as tightly as the grace-period path.
Event::dispatch(new UserErasureRequested($request));
return $this->completeErasure($request);
}
/**
* Cancels a pending request. For a User-scoped request, reactivates the
* account (see requestErasureForUser()). A Customer-scoped request never
* deactivated anything, so there's nothing to reactivate for it. No-op
* (returns false) if the request isn't pending — e.g. already completed or
* cancelled.
*/
public function cancelErasure(DataErasureRequest $request): bool
{
if (! $request->isPending()) {
return false;
}
$request->update([
'status' => ErasureRequestStatus::Cancelled,
'cancelled_at' => now(),
]);
if (! $request->isForCustomer()) {
$this->setUserDeactivated($request->subject_id, false);
}
return true;
}
/**
* Actually erases the data for a due request: runs every registered
* provider's *ForCustomer() or *ForUser() method (whichever matches the
* request's subject), records the outcome on the request, and marks it
* completed. Called by privacy:process-erasure-requests — not meant to be
* called directly for a request that hasn't passed its grace period, since
* that defeats the point of the window; ProcessErasureRequestsCommand
* enforces isDue() before calling this.
*/
public function completeErasure(DataErasureRequest $request): ErasureReport
{
if ($request->isForCustomer()) {
$subject = new CustomerSubject(customerId: $request->subject_id);
$results = array_map(fn ($provider) => $provider->eraseForCustomer($subject), $this->manager->providers());
} else {
$subject = new UserSubject(userId: $request->subject_id, email: $request->email);
$results = array_map(fn ($provider) => $provider->eraseForUser($subject), $this->manager->providers());
}
$report = new ErasureReport($subject, $results);
$request->update([
'status' => ErasureRequestStatus::Completed,
'completed_at' => now(),
'report' => array_map(
fn (ProviderErasureResult $result) => [
'provider' => $result->provider,
'outcome' => $result->outcome->value,
'reason' => $result->reason,
],
$results
),
]);
return $report;
}
private function setUserDeactivated(int $userId, bool $deactivated): void
{
$model = config('auth.providers.users.model');
/** @var class-string<Model> $model */
$model::where('id', $userId)->update([
'deactivated_at' => $deactivated ? now() : null,
]);
}
}
-18
View File
@@ -1,18 +0,0 @@
<?php
namespace Modules\Core\Privacy;
/**
* One provider's outcome on an erasure request. `reason` is required whenever
* outcome isn't Erased, so a compliance report or admin UI can show *why* something
* wasn't deleted (e.g. "orders retained per tax law for 7 years from placement")
* without reading that module's source.
*/
class ProviderErasureResult
{
public function __construct(
public readonly string $provider,
public readonly ErasureOutcome $outcome,
public readonly ?string $reason = null,
) {}
}
-19
View File
@@ -1,19 +0,0 @@
<?php
namespace Modules\Core\Privacy;
/**
* One provider's contribution to a right-of-access export. `provider` is a short,
* stable machine name (e.g. 'customer', 'orders', 'reviews') used as the top-level
* key when PrivacyService assembles every provider's data into one export payload.
*/
class ProviderExportResult
{
/**
* @param array<string, mixed> $data
*/
public function __construct(
public readonly string $provider,
public readonly array $data,
) {}
}
@@ -1,63 +0,0 @@
<?php
namespace Modules\Core\Privacy\Providers;
use Lunar\Models\Address;
use Modules\Core\Privacy\Contracts\PersonalDataProvider;
use Modules\Core\Privacy\CustomerSubject;
use Modules\Core\Privacy\ErasureOutcome;
use Modules\Core\Privacy\ProviderErasureResult;
use Modules\Core\Privacy\ProviderExportResult;
use Modules\Core\Privacy\UserSubject;
/**
* A customer's saved addresses (lunar_addresses) — belong to the Customer
* (business account) via customer_id, not to an individual User, so this is
* Customer-scope only. No legal retention requirement of their own (unlike
* OrderAddress, handled by OrderDataProvider), so they're freely deleted outright
* rather than pseudonymized in place.
*/
class AddressDataProvider implements PersonalDataProvider
{
public function name(): string
{
return 'addresses';
}
public function exportForCustomer(CustomerSubject $subject): ProviderExportResult
{
$addresses = Address::where('customer_id', $subject->customerId)->get();
return new ProviderExportResult('addresses', $addresses->map(fn (Address $address) => [
'id' => $address->id,
'first_name' => $address->first_name,
'last_name' => $address->last_name,
'company_name' => $address->company_name,
'line_one' => $address->line_one,
'line_two' => $address->line_two,
'line_three' => $address->line_three,
'city' => $address->city,
'state' => $address->state,
'postcode' => $address->postcode,
'contact_email' => $address->contact_email,
'contact_phone' => $address->contact_phone,
])->all());
}
public function exportForUser(UserSubject $subject): ProviderExportResult
{
return new ProviderExportResult('addresses', []);
}
public function eraseForCustomer(CustomerSubject $subject): ProviderErasureResult
{
Address::where('customer_id', $subject->customerId)->delete();
return new ProviderErasureResult('addresses', ErasureOutcome::Erased);
}
public function eraseForUser(UserSubject $subject): ProviderErasureResult
{
return new ProviderErasureResult('addresses', ErasureOutcome::Skipped, 'Addresses belong to Customer accounts, not individual users.');
}
}
@@ -1,62 +0,0 @@
<?php
namespace Modules\Core\Privacy\Providers;
use Lunar\Models\Cart;
use Lunar\Models\CartAddress;
use Modules\Core\Privacy\Contracts\PersonalDataProvider;
use Modules\Core\Privacy\CustomerSubject;
use Modules\Core\Privacy\ErasureOutcome;
use Modules\Core\Privacy\ProviderErasureResult;
use Modules\Core\Privacy\ProviderExportResult;
use Modules\Core\Privacy\UserSubject;
/**
* Carts and cart addresses (lunar_carts, lunar_cart_addresses) belong to the
* Customer (business account) via customer_id, not to an individual User, so this
* is Customer-scope only. Unlike Order/OrderAddress, an abandoned cart has no
* legal retention requirement, so its addresses are freely deleted. The Cart row
* itself is left alone (any completed order it produced is handled separately by
* OrderDataProvider, which is what retention law actually cares about) — only its
* address PII is removed.
*/
class CartDataProvider implements PersonalDataProvider
{
public function name(): string
{
return 'carts';
}
public function exportForCustomer(CustomerSubject $subject): ProviderExportResult
{
$addresses = CartAddress::whereIn('cart_id', Cart::where('customer_id', $subject->customerId)->pluck('id'))->get();
return new ProviderExportResult('carts', $addresses->map(fn (CartAddress $address) => [
'type' => $address->type,
'first_name' => $address->first_name,
'last_name' => $address->last_name,
'line_one' => $address->line_one,
'city' => $address->city,
'postcode' => $address->postcode,
'contact_email' => $address->contact_email,
'contact_phone' => $address->contact_phone,
])->all());
}
public function exportForUser(UserSubject $subject): ProviderExportResult
{
return new ProviderExportResult('carts', []);
}
public function eraseForCustomer(CustomerSubject $subject): ProviderErasureResult
{
CartAddress::whereIn('cart_id', Cart::where('customer_id', $subject->customerId)->pluck('id'))->delete();
return new ProviderErasureResult('carts', ErasureOutcome::Erased);
}
public function eraseForUser(UserSubject $subject): ProviderErasureResult
{
return new ProviderErasureResult('carts', ErasureOutcome::Skipped, 'Carts belong to Customer accounts, not individual users.');
}
}
@@ -1,115 +0,0 @@
<?php
namespace Modules\Core\Privacy\Providers;
use Lunar\Models\Customer;
use Modules\Core\Privacy\Contracts\PersonalDataProvider;
use Modules\Core\Privacy\CustomerSubject;
use Modules\Core\Privacy\ErasureOutcome;
use Modules\Core\Privacy\ProviderErasureResult;
use Modules\Core\Privacy\ProviderExportResult;
use Modules\Core\Privacy\UserSubject;
/**
* The Customer record itself (lunar_customers) and, on the User side, the User's
* own name/email. This is the one provider that implements both scopes
* meaningfully, and they are deliberately kept from touching each other's data:
*
* - eraseForCustomer() clears the account's own fields (name, company, tax id)
* only — it never touches any linked User's login or identity, even though
* $customer->users exists. Erasing a business account must not destroy the
* login access of every person who works there.
* - eraseForUser() clears that one person's name/email only — it never touches
* the Customer record's own fields, and it also detaches the User from every
* Customer they're linked to (the customer_user pivot — see docs/modules.md
* "Customer/User Pairing"), since erasing a person's identity should end
* their membership everywhere, without erasing the business accounts
* themselves or any other User still linked to them.
*
* No legal retention requirement applies to this table on its own, so both
* directions are freely erased — Order/OrderAddress, which DO have a retention
* requirement, are handled separately by OrderDataProvider.
*/
class CustomerDataProvider implements PersonalDataProvider
{
public function name(): string
{
return 'customer';
}
public function exportForCustomer(CustomerSubject $subject): ProviderExportResult
{
$customer = Customer::find($subject->customerId);
return new ProviderExportResult('customer', $customer ? [
'id' => $customer->id,
'title' => $customer->title,
'first_name' => $customer->first_name,
'last_name' => $customer->last_name,
'company_name' => $customer->company_name,
'tax_identifier' => $customer->tax_identifier,
'meta' => $customer->meta,
'users' => $customer->users->map(fn ($user) => [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
])->all(),
] : []);
}
public function exportForUser(UserSubject $subject): ProviderExportResult
{
$model = config('auth.providers.users.model');
$user = $model::find($subject->userId);
return new ProviderExportResult('customer', $user ? [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'customers' => $user->customers->map(fn (Customer $customer) => [
'id' => $customer->id,
'company_name' => $customer->company_name,
])->all(),
] : []);
}
public function eraseForCustomer(CustomerSubject $subject): ProviderErasureResult
{
$customer = Customer::find($subject->customerId);
if (! $customer) {
return new ProviderErasureResult('customer', ErasureOutcome::Skipped, 'Customer record not found.');
}
$customer->update([
'title' => null,
'first_name' => 'Erased',
'last_name' => "Customer #{$customer->id}",
'company_name' => null,
'tax_identifier' => null,
'account_ref' => null,
'meta' => null,
]);
return new ProviderErasureResult('customer', ErasureOutcome::Erased);
}
public function eraseForUser(UserSubject $subject): ProviderErasureResult
{
$model = config('auth.providers.users.model');
$user = $model::find($subject->userId);
if (! $user) {
return new ProviderErasureResult('customer', ErasureOutcome::Skipped, 'User record not found.');
}
$user->customers()->detach();
$user->update([
'name' => null,
'email' => "erased-user-{$user->id}@example.invalid",
]);
return new ProviderErasureResult('customer', ErasureOutcome::Erased);
}
}
@@ -1,97 +0,0 @@
<?php
namespace Modules\Core\Privacy\Providers;
use Lunar\Models\Order;
use Lunar\Models\OrderAddress;
use Modules\Core\Privacy\Contracts\PersonalDataProvider;
use Modules\Core\Privacy\CustomerSubject;
use Modules\Core\Privacy\ErasureOutcome;
use Modules\Core\Privacy\ProviderErasureResult;
use Modules\Core\Privacy\ProviderExportResult;
use Modules\Core\Privacy\UserSubject;
/**
* Orders and order addresses (lunar_orders, lunar_order_addresses) belong to the
* Customer (business account) via customer_id, not to an individual User, so this
* is Customer-scope only. They're also subject to legal retention (tax/accounting
* law generally requires invoices be kept for several years — GDPR Art. 17(3)(b)
* explicitly allows this to override an erasure request). eraseForCustomer()
* therefore pseudonymizes the PII-bearing free-text fields in place rather than
* deleting the order: totals, line items, tax data, and the order itself all
* remain intact and auditable.
*/
class OrderDataProvider implements PersonalDataProvider
{
public function name(): string
{
return 'orders';
}
public function exportForCustomer(CustomerSubject $subject): ProviderExportResult
{
$orders = Order::where('customer_id', $subject->customerId)->with('addresses')->get();
return new ProviderExportResult('orders', $orders->map(fn (Order $order) => [
'id' => $order->id,
'reference' => $order->reference,
'status' => $order->status,
'total' => $order->total?->decimal(),
'placed_at' => $order->placed_at?->toIso8601String(),
'addresses' => $order->addresses->map(fn (OrderAddress $address) => [
'type' => $address->type,
'first_name' => $address->first_name,
'last_name' => $address->last_name,
'line_one' => $address->line_one,
'city' => $address->city,
'postcode' => $address->postcode,
'contact_email' => $address->contact_email,
'contact_phone' => $address->contact_phone,
])->all(),
])->all());
}
public function exportForUser(UserSubject $subject): ProviderExportResult
{
return new ProviderExportResult('orders', []);
}
public function eraseForCustomer(CustomerSubject $subject): ProviderErasureResult
{
$orderIds = Order::where('customer_id', $subject->customerId)->pluck('id');
if ($orderIds->isEmpty()) {
return new ProviderErasureResult('orders', ErasureOutcome::Skipped, 'No orders for this customer.');
}
Order::whereIn('id', $orderIds)->update([
'customer_reference' => null,
'notes' => null,
]);
OrderAddress::whereIn('order_id', $orderIds)->update([
'title' => null,
'first_name' => 'Erased',
'last_name' => 'Customer',
'company_name' => null,
'tax_identifier' => null,
'line_one' => null,
'line_two' => null,
'line_three' => null,
'delivery_instructions' => null,
'contact_email' => null,
'contact_phone' => null,
]);
return new ProviderErasureResult(
'orders',
ErasureOutcome::Pseudonymized,
'Order and address free-text fields cleared; order records, totals, and line items retained for legal/tax record-keeping.'
);
}
public function eraseForUser(UserSubject $subject): ProviderErasureResult
{
return new ProviderErasureResult('orders', ErasureOutcome::Skipped, 'Orders belong to Customer accounts, not individual users.');
}
}
@@ -1,99 +0,0 @@
<?php
namespace Modules\Core\Privacy\Providers;
use Illuminate\Database\Eloquent\Builder;
use Modules\Core\Privacy\Contracts\PersonalDataProvider;
use Modules\Core\Privacy\CustomerSubject;
use Modules\Core\Privacy\ErasureOutcome;
use Modules\Core\Privacy\ProviderErasureResult;
use Modules\Core\Privacy\ProviderExportResult;
use Modules\Core\Privacy\UserSubject;
use Modules\Core\Review\Models\ProductReview;
/**
* ProductReview (product_reviews) has no FK to Customer/User at all — it's
* deliberately anonymous, just free-text reviewer_name/reviewer_email (see
* docs/product-listing.md "Reviews"). A review is authored by an individual, not a
* business account, so this is User-scope only — matched best-effort by email
* against UserSubject::$email.
*
* NEEDS REVIEW: moved from Customer-scope to User-scope during the User/Customer
* split (see docs/privacy.md "User-scope vs Customer-scope") on the reasoning that
* authorship is a personal attribute — but this hasn't been fully validated against
* how reviews are actually attributed in this codebase; revisit before relying on
* it for a real erasure/export request.
*
* Matching by email is itself a real, documented limitation regardless of scope: a
* review submitted under a different email than the one on file won't be found.
* There's no stronger signal available without changing ProductReview's schema.
*/
class ReviewDataProvider implements PersonalDataProvider
{
public function name(): string
{
return 'reviews';
}
public function exportForCustomer(CustomerSubject $subject): ProviderExportResult
{
return new ProviderExportResult('reviews', []);
}
public function exportForUser(UserSubject $subject): ProviderExportResult
{
if (! $subject->email) {
return new ProviderExportResult('reviews', []);
}
$reviews = $this->matchingReviews($subject->email)->get();
return new ProviderExportResult('reviews', $reviews->map(fn (ProductReview $review) => [
'id' => $review->id,
'product_id' => $review->product_id,
'title' => $review->title,
'body' => $review->body,
'rating' => $review->rating,
'reviewer_name' => $review->reviewer_name,
'reviewer_email' => $review->reviewer_email,
'reviewed_at' => $review->reviewed_at?->toIso8601String(),
])->all());
}
public function eraseForCustomer(CustomerSubject $subject): ProviderErasureResult
{
return new ProviderErasureResult('reviews', ErasureOutcome::Skipped, 'Reviews are authored by individuals, not Customer accounts.');
}
public function eraseForUser(UserSubject $subject): ProviderErasureResult
{
if (! $subject->email) {
return new ProviderErasureResult('reviews', ErasureOutcome::Skipped, 'No email on this subject to match reviews by.');
}
$matched = $this->matchingReviews($subject->email)->count();
if ($matched === 0) {
return new ProviderErasureResult('reviews', ErasureOutcome::Skipped, 'No reviews matched this email.');
}
// The review content itself (rating/title/body) is kept — it's the
// reviewer's own product feedback, not identity data on its own — only
// the identifying fields are cleared.
$this->matchingReviews($subject->email)->update([
'reviewer_name' => 'Anonymous',
'reviewer_email' => null,
]);
return new ProviderErasureResult(
'reviews',
ErasureOutcome::Pseudonymized,
'Reviewer name/email cleared on reviews matched by email; rating/title/body text retained.'
);
}
private function matchingReviews(string $email): Builder
{
return ProductReview::where('reviewer_email', $email);
}
}
-30
View File
@@ -1,30 +0,0 @@
<?php
namespace Modules\Core\Privacy;
use Illuminate\Contracts\Auth\Authenticatable;
use Lunar\Base\LunarUser;
/**
* Identifies "the person" for a User-scoped data-subject request — erasing/
* exporting one individual's own identity (login, name, email) wherever it
* appears, regardless of how many Customer (business) accounts they're linked to.
* Deliberately carries no customerId: a provider that needs to know which
* Customer accounts this User is linked to (e.g. to detach them, or to find data
* keyed by a shared email) looks that up itself, rather than this value object
* assuming one fixed Customer — the whole point is that one User can belong to
* many Customer accounts (B2B multi-seat access) and erasing the User must not
* assume or privilege any single one of them.
*/
class UserSubject
{
public function __construct(
public readonly int $userId,
public readonly ?string $email = null,
) {}
public static function forUser(Authenticatable&LunarUser $user): self
{
return new self(userId: $user->id, email: $user->email);
}
}
+2 -3
View File
@@ -10,7 +10,6 @@ use Modules\Core\Command\ExportCommand;
use Modules\Core\Command\ImportCommand;
use Modules\Core\Command\InstallLunarCommand;
use Modules\Core\Command\MigrateImportCommand;
use Modules\Core\Command\ProcessErasureRequestsCommand;
class CoreServiceProvider extends ServiceProvider
{
@@ -36,10 +35,10 @@ class CoreServiceProvider extends ServiceProvider
], 'core-assets');
if ($this->app->runningInConsole()) {
$this->commands([AnonymizeCommand::class, ExportCommand::class, ExportCleanupCommand::class, ImportCommand::class, MigrateImportCommand::class, ProcessErasureRequestsCommand::class]);
$this->commands([AnonymizeCommand::class, ExportCommand::class, ExportCleanupCommand::class, ImportCommand::class, MigrateImportCommand::class]);
//Overriding lunar:install
$this->app->booted(fn() => $this->commands([InstallLunarCommand::class]));
$this->app->booted(fn () => $this->commands([InstallLunarCommand::class]));
}
}
}
+13 -2
View File
@@ -11,15 +11,26 @@ use Modules\Core\Localization\Events\LanguageUpdated;
use Modules\Core\Localization\Events\TranslationCreated;
use Modules\Core\Localization\Events\TranslationDeleted;
use Modules\Core\Localization\Events\TranslationUpdated;
use Modules\Core\Localization\LanguageCacheObserver;
use Modules\Core\Localization\Listeners\FlushLanguageCache;
use Modules\Core\Localization\Listeners\FlushTranslationCache;
use Modules\Core\Localization\Listeners\LogTranslationActivity;
use Modules\Core\Localization\Listeners\MigrateTranslationsForRenamedLanguage;
use Modules\Core\Localization\LocaleMiddleware;
use Modules\Core\Localization\Middleware\LocaleMiddleware;
use Modules\Core\Localization\Models\LanguageLine;
use Modules\Core\Localization\Observers\LanguageCacheObserver;
class LocalizationServiceProvider extends ServiceProvider
{
public function register(): void
{
// Must run before Spatie\TranslationLoader\TranslationServiceProvider's
// register() merges its own config defaults - mergeConfigFrom() only fills
// in keys not already set, so setting this here (regardless of provider
// boot order) makes it win over the package's default
// Spatie\TranslationLoader\LanguageLine::class.
config(['translation-loader.model' => LanguageLine::class]);
}
public function boot(): void
{
$this->app['router']->aliasMiddleware('locale', LocaleMiddleware::class);
-22
View File
@@ -1,22 +0,0 @@
<?php
namespace Modules\Core\Providers;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
use Modules\Core\Auth\Events\UserAuthenticated;
use Modules\Core\Privacy\Events\PersonalDataGathered;
use Modules\Core\Privacy\Events\UserErasureRequested;
use Modules\Core\Privacy\Listeners\CancelErasureOnLoginListener;
use Modules\Core\Privacy\Listeners\CascadeCustomerErasureListener;
use Modules\Core\Privacy\Listeners\WriteExportToCsvListener;
class PrivacyServiceProvider extends ServiceProvider
{
public function boot(): void
{
Event::listen(UserAuthenticated::class, CancelErasureOnLoginListener::class);
Event::listen(PersonalDataGathered::class, WriteExportToCsvListener::class);
Event::listen(UserErasureRequested::class, CascadeCustomerErasureListener::class);
}
}
+2 -2
View File
@@ -9,8 +9,8 @@ use Modules\Core\Review\Models\ProductReview;
* Keeps a product's Meilisearch document in sync with its reviews. A review is
* created/edited independently of its product (customer submission, staff reply),
* so the product's own save/update events never fire for it — without this listener,
* Modules\Core\Search\ProductIndexer's review data would only refresh on the next
* full product reindex.
* Modules\Core\Product\Services\ProductIndexer's review data would only refresh on
* the next full product reindex.
*/
class ReviewServiceProvider extends ServiceProvider
{
+22
View File
@@ -5,8 +5,11 @@ namespace Modules\Core\Review\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Lunar\Models\Product;
use Spatie\Image\Enums\BorderType;
use Spatie\Image\Enums\Fit;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
class ProductReview extends Model implements HasMedia
{
@@ -30,4 +33,23 @@ class ProductReview extends Model implements HasMedia
{
$this->addMediaCollection(self::IMAGES_COLLECTION);
}
/**
* Unlike Product/ProductVariant, this model sits outside Lunar's own
* MediaDefinitionsInterface (Lunar\Base\StandardMediaDefinitions), which is
* what registers the 'small' conversion those models get automatically. Without
* this, Modules\Core\Product\Services\ProductIndexer::mapMedia() — shared across
* product, variant, and review media — throws Spatie\MediaLibrary\MediaCollections\
* Exceptions\InvalidConversion the first time a review has an image, since
* $media->getUrl('small') has no matching conversion to resolve.
*/
public function registerMediaConversions(?Media $media = null): void
{
$this->addMediaConversion('small')
->fit(Fit::Fill, 300, 300)
->border(0, BorderType::Overlay, color: '#FFF')
->background('#FFF')
->sharpen(10)
->keepOriginalImageFormat();
}
}
-177
View File
@@ -1,177 +0,0 @@
<?php
namespace Modules\Core\Search;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Lunar\Models\Currency;
use Lunar\Models\Price;
use Lunar\Models\Product;
use Lunar\Models\ProductVariant;
use Lunar\Search\ProductIndexer as BaseProductIndexer;
use Modules\Core\Review\Models\ProductReview;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
/**
* Extends Lunar's own indexer so Modules\Core\Catalog\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)
* - 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
* - price (cheapest variant, filterable) and full per-variant pricing
* - 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
*
* A review is created/edited independently of its product (Modules\Core\Providers\
* ReviewServiceProvider re-indexes the product on review create/update/delete), so
* this data doesn't go stale between full reindexes.
*
* New fields aren't filterable in Meilisearch until `php artisan lunar:meilisearch:setup`
* re-syncs index settings, and existing documents need `lunar:search:index --refresh` to
* pick up the new shape — see docs/product-listing.md. If SCOUT_QUEUE is enabled, the
* queue worker also needs restarting after deploying changes to this class (see
* docs/lunar.md "Gotchas" — a running worker keeps stale indexer code in memory).
*/
class ProductIndexer extends BaseProductIndexer
{
public function getFilterableFields(): array
{
return [
...parent::getFilterableFields(),
'id',
'brand',
'collections',
'price',
'slugs',
];
}
public function makeAllSearchableUsing(Builder $query): Builder
{
return parent::makeAllSearchableUsing($query)->with([
'collections',
'media',
'tags',
'urls',
'variants.images',
'variants.prices',
'variants.values.option',
]);
}
public function toSearchableArray(Model $model): array
{
/** @var Product $model */
$data = parent::toSearchableArray($model);
$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['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);
return $data;
}
private function mapVariant(ProductVariant $variant, Currency $currency): array
{
return [
'id' => $variant->id,
'sku' => $variant->sku,
'stock' => $variant->stock,
'purchasable' => $variant->purchasable,
'options' => $variant->values->map(fn ($value) => [
'option' => $this->translatedName($value->option->name),
'value' => $this->translatedName($value->name),
'meta' => $value->meta,
])->all(),
'prices' => $variant->prices->map(fn (Price $price) => [
'currency_id' => $price->currency_id,
'customer_group_id' => $price->customer_group_id,
'price' => $price->price->decimal(),
'compare_price' => $price->compare_price?->decimal(),
'min_quantity' => $price->min_quantity,
])->all(),
'media' => $variant->images->map(fn (Media $media) => $this->mapMedia($media))->all(),
];
}
/**
* Public-safe fields only — reviewer_email is PII with no storefront use and is
* deliberately excluded, unlike every other column on the review. reply/replied_at
* (the staff response) are included since they're meant to be shown alongside the
* review on the storefront.
*/
private function mapReview(ProductReview $review): array
{
return [
'id' => $review->id,
'title' => $review->title,
'body' => $review->body,
'rating' => $review->rating,
'reviewed_at' => $review->reviewed_at?->timestamp,
'reviewer_name' => $review->reviewer_name,
'reply' => $review->reply,
'replied_at' => $review->replied_at?->timestamp,
'location' => $review->location,
'media' => $review->media->map(fn (Media $media) => $this->mapMedia($media))->all(),
];
}
/**
* ProductOption/ProductOptionValue's `name` is a plain locale-keyed array cast
* (AsArrayObject) directly on the column — unlike Product/Collection/Brand, it is
* not stored in attribute_data. Lunar's translateAttribute() only reads
* attribute_data, so it silently returns null for these two models; this reads
* the array directly instead. Falls back to the first available locale if the
* current one is missing. Not a general replacement for translateAttribute() —
* every other translated field in this indexer (product/collection name and
* description) genuinely is attribute_data-backed and translateAttribute() is
* correct for those.
*/
private function translatedName(mixed $name): ?string
{
$names = is_array($name) ? $name : (array) $name;
return $names[app()->getLocale()] ?? reset($names) ?: null;
}
private function mapMedia(Media $media): array
{
return [
'id' => $media->id,
'url' => $media->getUrl(),
'thumb' => $media->getUrl('small'),
];
}
/**
* The cheapest variant's base price (no customer group) in the default currency,
* as a float in major units — e.g. 19.99, not 1999. Null if the product has no
* variant with a price in that currency yet, so it's excluded from price filters
* rather than sorting to the bottom as if it were free.
*/
private function cheapestPrice(Product $model, Currency $currency): ?float
{
$price = $model->variants
->flatMap(fn ($variant) => $variant->prices)
->filter(fn ($price) => $price->currency_id === $currency->id && $price->customer_group_id === null)
->min(fn ($price) => $price->price->value);
return $price !== null ? $price / (10 ** $currency->decimal_places) : null;
}
}
-56
View File
@@ -1,56 +0,0 @@
<?php
namespace Modules\Core\Search;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\App;
use Lunar\Facades\AttributeManifest;
use Lunar\Models\Language;
use Lunar\Models\Product;
/**
* Lunar's Meilisearch indexer flattens translated attributes into locale-suffixed
* fields on a single document (name_en, name_el, description_en, description_el —
* see Lunar\Search\ScoutIndexer::mapSearchableAttributes()), not separate indexes
* or a filterable locale field. Locale-aware search means choosing which fields
* to search on, not filtering results by locale.
*/
class ProductSearchService
{
/**
* @return Collection<int, Product>
*/
public function search(string $query, ?string $locale = null): Collection
{
$locale ??= App::getLocale();
$defaultLocale = Language::getDefault()->code;
return Product::search($query)
->options([
'attributesToSearchOn' => $this->searchableFields($locale, $defaultLocale),
])
->get();
}
/**
* Target the resolved locale's fields plus the default locale's fields, so a
* product that's only ever been translated into the default language still
* surfaces when searched in another locale, instead of becoming invisible
* until every product is fully translated.
*
* @return array<int, string>
*/
private function searchableFields(string $locale, string $defaultLocale): array
{
$handles = AttributeManifest::getSearchableAttributes(Product::morphName())
->pluck('handle');
$locales = array_unique([$locale, $defaultLocale]);
return $handles
->crossJoin($locales)
->map(fn (array $pair) => "{$pair[0]}_{$pair[1]}")
->values()
->all();
}
}