Compare commits

..
14 Commits
Author SHA1 Message Date
arvanitakis 59303cf25f Feature: Updating Readme to reflect changes on Privacy 2026-08-24 21:44:10 +03:00
arvanitakis af380a7fa0 Feature: Handling Cases for User to Customer Relationships
This commit handles a case where customer data are "dead-data" menaing there is no way of erasure for them, which makes the app non-compliant
2026-08-24 21:41:23 +03:00
arvanitakis 9f540cbaa4 Feature: Creating Privacy Basics 2026-08-24 21:06:11 +03:00
arvanitakis e31de1b4e3 Bump version to 0.5.0 2026-08-24 14:47:59 +03:00
arvanitakis 113fa35da7 Hotfix: Adding a Back Button to Boboko's Login Form 2026-08-24 14:45:44 +03:00
arvanitakis 7c199cc3bd Feature: Updating Products Service and Locale MIddleware 2026-08-24 14:41:50 +03:00
arvanitakis 5cb6c529a0 Feature: Adding Product Service to Lunar
This commit introduces a Product service to Lunar. This product service calls Meilisearch to fetch an indexed product. The indexer has been updated to also include the collection and the price of the product.

A product search service has also been created to be used by the frontend's search
2026-08-24 12:41:28 +03:00
arvanitakis f7da26b487 Bump version to 0.4.0 2026-08-06 12:24:07 +03:00
arvanitakis 1857ced3a2 Feature: Adding Translation Service and Translation Seeder 2026-08-06 12:06:55 +03:00
arvanitakis 8646dca16a Feature: Adding Spatie's Translation Loader 2026-08-06 00:13:13 +03:00
arvanitakis 93469d033f Feature: Adding Locale Middleware 2026-08-05 23:58:53 +03:00
arvanitakis 4c00369005 Bump version to 0.3.0 2026-07-12 05:38:14 +03:00
arvanitakis cf6aa343f1 Feat: Creating Product Indexer to Strip HTML tags from Products 2026-07-12 05:34:43 +03:00
arvanitakis ee9893bd64 Feature: Adding Meilisearch to core 2026-07-12 05:28:23 +03:00
76 changed files with 4223 additions and 27 deletions
+37
View File
@@ -4,6 +4,43 @@ 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.5.0] - 2026-08-24
### Added
- **`Modules\Core\Catalog\ProductService`**: storefront product listing/filtering (`list()`) and single-product lookup (`getById()`, `getBySlug()`), reading directly from the Meilisearch index rather than the database — one data source, no `->get()` model hydration. Returns plain arrays (not Eloquent models), meant to be called directly from a consuming app's controllers.
- `ProductFilters` DTO: optional `collectionId`, `brand`, `minPrice`, `maxPrice`, translated into a Meilisearch `filter` expression.
- Listing results are locale-aware: `withLocalizedFields()` resolves `name`/`description` from the indexer's per-locale fields, falling back to the store's default language (via `LocaleMiddleware::defaultLocale()`) when the current locale has no translation yet, instead of rendering blank.
- `Modules\Core\Search\ProductIndexer` expanded well beyond its original collection/price additions to carry everything a detail page needs: `id`/`slugs` (filterable — `getById()`/`getBySlug()` resolve purely from the index, no database read), `collection_names`, `tags`, the full media gallery, per-variant data (`sku`, `stock`, `purchasable`, translated option/value names + `meta` for swatches, per-currency prices, variant media), and reviews (`reviews`, `review_count`, `average_rating` — public-safe fields only, `reviewer_email` deliberately excluded).
- `Modules\Core\Providers\ReviewServiceProvider` (newly registered): re-indexes a product whenever one of its reviews is created/updated/deleted, since a review write doesn't touch the `Product` row and so never fires the product's own model events.
- **`Modules\Core\Search\ProductSearchService`**: locale-aware full-text product search on top of the same Meilisearch index, for use by a storefront's search bar — separate from `ProductService`, which is for browsing/filtering without a query term.
- `docs/product-listing.md` and `docs/product-search.md` — usage, full field reference, and design notes for the two services above.
- `docs/lunar.md` "Gotchas": three new entries hit while building this — `ProductOption`/`ProductOptionValue::name` isn't `attribute_data` (so `translateAttribute()` silently returns `null` for it), a running `queue:work` process not picking up an edited Scout indexer class, and Scout's `paginateRaw()->items()` on the Meilisearch driver returning the whole raw response rather than a hit list.
### Fixed
- The admin login form (`Modules\Core\Auth\Filament\Pages\Login`) had no way back from the OTP-entry step to the email step short of reloading the page. A `back()` method resets to the email step; a "← Back" link/button is shown on the OTP step only.
## [0.4.0] - 2026-08-06
### Added
- **Locale-prefixed routing** (`Modules\Core\Localization\LocaleMiddleware`): a `locale` route-middleware alias, opt-in per shop (not pushed onto the `web` group globally, since admin/Livewire/webhook routes must not be locale-redirected). Reads the first URL segment against Lunar's own `languages` table, sets `App::setLocale()`, and redirects unprefixed/unknown-locale requests to a resolved locale (`Accept-Language` match → default language → first language). Every locale is prefixed, including the default (`/el/...`, `/en/...`), never a bare root — avoids the hreflang/duplicate-content ambiguity of a bare-root default locale.
- Language list cached with `Cache::rememberForever()`, invalidated via `Modules\Core\Localization\LanguageCacheObserver` dispatching `LanguageCreated`/`LanguageUpdated`/`LanguageDeleted` events (see below) rather than doing the work itself.
- **Language rename safety**: renaming a `Language::code` (e.g. `el` → `gr`) no longer strands existing translations. `MigrateTranslationsForRenamedLanguage` (listening on `LanguageUpdated`) migrates every affected `LanguageLine.text` key from the old code to the new one and flushes both codes' translation caches — closing a real data-loss gap where a rename would otherwise make existing `LanguageLine` translations permanently unreachable.
- **Storefront UI label translations**: pulled in `spatie/laravel-translation-loader` (self-registers via Composer package auto-discovery; its loader *extends* Laravel's file-based `FileLoader` and merges DB translations on top — existing Filament/Lunar vendor `lang/` strings are unaffected). Labels are looked up via Laravel's native `__('storefront.nav.cart')`, kept in its own `storefront` group so nothing collides with Lunar/Filament's own translation groups.
- `Modules\Core\Command\InstallLunarCommand` (overriding `lunar:install`) seeds a starter set of ~15 common e-shop labels (`nav.*`, `cart.*`, `product.*`, `auth.*`, `search.*`, English + Greek), idempotently guarded so it's safe on every boot.
- `Modules\Core\Localization\TranslationReader::group('storefront')` returns the whole reduced/cached label array for a locale (backed by `LanguageLine`'s own forever-cache) — for sharing to a view as `$labels` or `@json()`-ing to JS, on top of `__()` for single-key Blade lookups.
- **Admin UI**: `Modules\Core\Localization\Filament\Resources\LanguageLineResource` (registered in `CorePlugin`) lists/searches/filters `language_lines` and edits each row's `group`, `key`, and one text input per locale currently in `lunar_languages` — locale columns/inputs are generated dynamically from the language list, so a new language needs no resource changes.
- **Event-driven writes**: `Modules\Core\Localization\TranslationService` (`create`/`update`/`delete`) is the single write path for `LanguageLine` — the Filament resource's Create/Edit/Delete pages route through it rather than Filament's default direct-model writes. Dispatches `TranslationCreated`/`TranslationUpdated` (carries the full pre-update `{group, key, text}` snapshot, so a bare rename is tracked the same as a text edit)/`TranslationDeleted`, each handled by two listeners:
- `FlushTranslationCache` — closes a real gap in `LanguageLine`'s own self-invalidation, which only flushes locales/groups present *after* a save. Flushes the union of old and new group+locale combinations, so a locale removed from `text`, or a `group`/`key` rename, can't leave a stale cached array behind.
- `LogTranslationActivity` — audits every write via the existing `Modules\Core\Logging\ActivityLogService` (`lunar` activity log channel), same `created`/`updated`/`deleted` shape as every other domain write in this project. Properties are flattened with `Arr::dot()` before logging (`text.en`, `text.el` instead of a nested `text` object) since Filament's Activity resource renders `properties` with a flat `KeyValue` field that can't display nested arrays.
- `Modules\Core\Providers\LocalizationServiceProvider` — split out of the growing `CoreServiceProvider` (per this project's own "split when a provider does too much" convention) to own all locale/translation middleware, observer, and event-listener registration.
## [0.3.0] - 2026-07-12
### Added
- **Meilisearch product search**: pulled in `lunarphp/search` (Lunar's driver-agnostic search abstraction — `database`/`meilisearch`/`typesense` engines, selectable via Scout's own `SCOUT_DRIVER` config) and `lunarphp/meilisearch`, wiring Meilisearch in as the search engine for products.
- `Search\ProductIndexer` overrides Lunar's own indexer to strip HTML tags from string fields (e.g. `name_en`, `description_en`) before they reach the search index — Lunar's default indexer sends raw attribute HTML straight through, which pollutes relevance ranking and highlighting with markup.
- Meilisearch itself is treated as app-level infrastructure, not a `boboko-core` concern: the actual Meilisearch container, host port, and master key live in each consuming app's own `docker-compose.yml`/`.env` (e.g. `3dealer`), the same way Postgres and Valkey do — `boboko-core` only declares the PHP package dependency and the indexing code.
## [0.2.0] - 2026-07-10
### Added
+117 -21
View File
@@ -1,6 +1,9 @@
# Core Module
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.
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.
---
@@ -8,13 +11,83 @@ A Laravel module providing authentication, notifications, activity logging, CLI
### OTP Authentication
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.
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.
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:**
@@ -33,9 +106,13 @@ 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).
@@ -43,8 +120,11 @@ 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
- `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
- Table-rate shipping (`ShippingPlugin`) registered by default
- `CorePlugin` — configures panel path, branding, logos, navigation items, and activity log
field exclusions for staff
Register the plugin in your Lunar panel provider:
@@ -52,30 +132,36 @@ 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 |
|---|---|
| `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 |
| `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) |
### Functional Types
Result and Option monads for explicit error handling without exceptions.
Result and Option types for explicit error handling without exceptions.
```php
// Result<T, E>
$result = Success::of($value);
$result = Error::of('something went wrong');
$result = Success::create($value);
$result = Error::create('something went wrong');
$result->map(fn($v) => ...)->flatMap(fn($v) => ...);
// Option<T>
$option = Option::fromValue($nullableValue);
$option->getOrElse('default');
$option->map(fn($v) => ...)->filter(fn($v) => $v > 0);
$option = Some::create($value);
$option = None::create();
$option->map(fn($v) => ...);
```
---
@@ -102,17 +188,22 @@ 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.2+
- Laravel 11+
- Lunar (lunarphp/lunar + lunarphp/admin)
- PHP 8.5+
- Laravel 12+
- Lunar 1.3 (`lunarphp/lunar`)
- Meilisearch (for product search/listing/catalog)
- Spatie Laravel Activity Log
---
@@ -120,7 +211,12 @@ php artisan migrate
## 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
+11 -4
View File
@@ -2,7 +2,7 @@
"name": "boboko/core",
"description": "Core module — authentication and shared panel behaviour",
"type": "library",
"version": "0.2.0",
"version": "0.5.0",
"autoload": {
"psr-4": {
"Modules\\Core\\": "src/"
@@ -14,7 +14,10 @@
"laravel/framework": "^12.0",
"laravel/tinker": "^3.0",
"symfony/yaml": "^7.0",
"lunarphp/table-rate-shipping": "^1.3"
"lunarphp/table-rate-shipping": "^1.3",
"lunarphp/search": "*",
"lunarphp/meilisearch": "*",
"spatie/laravel-translation-loader": "^2.8"
},
"require-dev": {
"fakerphp/faker": "^1.23",
@@ -31,13 +34,17 @@
"providers": [
"Modules\\Core\\Providers\\CoreServiceProvider",
"Modules\\Core\\Providers\\AuthServiceProvider",
"Modules\\Core\\Providers\\CustomerServiceProvider"
"Modules\\Core\\Providers\\CustomerServiceProvider",
"Modules\\Core\\Providers\\LocalizationServiceProvider",
"Modules\\Core\\Providers\\ReviewServiceProvider",
"Modules\\Core\\Providers\\PrivacyServiceProvider"
]
}
},
"config": {
"allow-plugins": {
"pestphp/pest-plugin": true
"pestphp/pest-plugin": true,
"php-http/discovery": true
}
}
}
+29
View File
@@ -16,4 +16,33 @@ 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,
],
];
+210
View File
@@ -0,0 +1,210 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Search Engine
|--------------------------------------------------------------------------
|
| This option controls the default search connection that gets used while
| using Laravel Scout. This connection is used when syncing all models
| to the search service. You should adjust this based on your needs.
|
| Supported: "algolia", "meilisearch", "typesense",
| "database", "collection", "null"
|
*/
'driver' => env('SCOUT_DRIVER', 'collection'),
/*
|--------------------------------------------------------------------------
| Index Prefix
|--------------------------------------------------------------------------
|
| Here you may specify a prefix that will be applied to all search index
| names used by Scout. This prefix may be useful if you have multiple
| "tenants" or applications sharing the same search infrastructure.
|
*/
'prefix' => env('SCOUT_PREFIX', ''),
/*
|--------------------------------------------------------------------------
| Queue Data Syncing
|--------------------------------------------------------------------------
|
| This option allows you to control if the operations that sync your data
| with your search engines are queued. When this is set to "true" then
| all automatic data syncing will get queued for better performance.
|
*/
'queue' => env('SCOUT_QUEUE', false),
/*
|--------------------------------------------------------------------------
| Database Transactions
|--------------------------------------------------------------------------
|
| This configuration option determines if your data will only be synced
| with your search indexes after every open database transaction has
| been committed, thus preventing any discarded data from syncing.
|
*/
'after_commit' => false,
/*
|--------------------------------------------------------------------------
| Chunk Sizes
|--------------------------------------------------------------------------
|
| These options allow you to control the maximum chunk size when you are
| mass importing data into the search engine. This allows you to fine
| tune each of these chunk sizes based on the power of the servers.
|
*/
'chunk' => [
'searchable' => 500,
'unsearchable' => 500,
],
/*
|--------------------------------------------------------------------------
| Soft Deletes
|--------------------------------------------------------------------------
|
| This option allows to control whether to keep soft deleted records in
| the search indexes. Maintaining soft deleted records can be useful
| if your application still needs to search for the records later.
|
*/
'soft_delete' => false,
/*
|--------------------------------------------------------------------------
| Identify User
|--------------------------------------------------------------------------
|
| This option allows you to control whether to notify the search engine
| of the user performing the search. This is sometimes useful if the
| engine supports any analytics based on this application's users.
|
| Supported engines: "algolia"
|
*/
'identify' => env('SCOUT_IDENTIFY', false),
/*
|--------------------------------------------------------------------------
| Algolia Configuration
|--------------------------------------------------------------------------
|
| Here you may configure your Algolia settings. Algolia is a cloud hosted
| search engine which works great with Scout out of the box. Just plug
| in your application ID and admin API key to get started searching.
|
*/
'algolia' => [
'id' => env('ALGOLIA_APP_ID', ''),
'secret' => env('ALGOLIA_SECRET', ''),
'index-settings' => [
// 'users' => [
// 'searchableAttributes' => ['id', 'name', 'email'],
// 'attributesForFaceting'=> ['filterOnly(email)'],
// ],
],
],
/*
|--------------------------------------------------------------------------
| Meilisearch Configuration
|--------------------------------------------------------------------------
|
| Here you may configure your Meilisearch settings. Meilisearch is an open
| source search engine with minimal configuration. Below, you can state
| the host and key information for your own Meilisearch installation.
|
| See: https://www.meilisearch.com/docs/learn/configuration/instance_options#all-instance-options
|
*/
'meilisearch' => [
'host' => env('MEILISEARCH_HOST', 'http://localhost:7700'),
'key' => env('MEILISEARCH_KEY'),
'index-settings' => [
// 'users' => [
// 'filterableAttributes'=> ['id', 'name', 'email'],
// ],
],
],
/*
|--------------------------------------------------------------------------
| Typesense Configuration
|--------------------------------------------------------------------------
|
| Here you may configure your Typesense settings. Typesense is an open
| source search engine using minimal configuration. Below, you will
| state the host, key, and schema configuration for the instance.
|
*/
'typesense' => [
'client-settings' => [
'api_key' => env('TYPESENSE_API_KEY', 'xyz'),
'nodes' => [
[
'host' => env('TYPESENSE_HOST', 'localhost'),
'port' => env('TYPESENSE_PORT', '8108'),
'path' => env('TYPESENSE_PATH', ''),
'protocol' => env('TYPESENSE_PROTOCOL', 'http'),
],
],
'nearest_node' => [
'host' => env('TYPESENSE_HOST', 'localhost'),
'port' => env('TYPESENSE_PORT', '8108'),
'path' => env('TYPESENSE_PATH', ''),
'protocol' => env('TYPESENSE_PROTOCOL', 'http'),
],
'connection_timeout_seconds' => env('TYPESENSE_CONNECTION_TIMEOUT_SECONDS', 2),
'healthcheck_interval_seconds' => env('TYPESENSE_HEALTHCHECK_INTERVAL_SECONDS', 30),
'num_retries' => env('TYPESENSE_NUM_RETRIES', 3),
'retry_interval_seconds' => env('TYPESENSE_RETRY_INTERVAL_SECONDS', 1),
],
// 'max_total_results' => env('TYPESENSE_MAX_TOTAL_RESULTS', 1000),
'model-settings' => [
// User::class => [
// 'collection-schema' => [
// 'fields' => [
// [
// 'name' => 'id',
// 'type' => 'string',
// ],
// [
// 'name' => 'name',
// 'type' => 'string',
// ],
// [
// 'name' => 'created_at',
// 'type' => 'int64',
// ],
// ],
// 'default_sorting_field' => 'created_at',
// ],
// 'search-parameters' => [
// 'query_by' => 'name'
// ],
// ],
],
'import_action' => env('TYPESENSE_IMPORT_ACTION', 'upsert'),
],
];
@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up(): void
{
Schema::create('language_lines', function (Blueprint $table) {
$table->id();
$table->string('group')->index();
$table->string('key');
$table->json('text');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down(): void
{
Schema::dropIfExists('language_lines');
}
};
@@ -0,0 +1,22 @@
<?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');
});
}
};
@@ -0,0 +1,56 @@
<?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');
}
};
@@ -0,0 +1,36 @@
<?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');
}
};
+215
View File
@@ -0,0 +1,215 @@
# Localization
Storefront routes can be locale-prefixed (`/el/proionta`, `/en/products`) using a middleware
that reads directly from Lunar's `languages` table — the same table the Filament **Languages**
resource manages, so there's no separate locale config to keep in sync.
---
## Why prefix every locale, including the default
Leaving the default locale bare at the root (`/proionta` for Greek, `/en/products` for English)
creates ambiguity: is `/` the language-neutral homepage or specifically the Greek version? It
also complicates `hreflang` (needs a self-referencing tag on the root plus a possibly-duplicate
`x-default`) and risks duplicate content if a bot or campaign link reaches the root without a
language signal.
Prefixing every locale avoids this: every URL unambiguously declares its language, `hreflang`
tags are symmetrical, and adding a locale later requires no URL restructuring.
---
## Opt-in, not global
The middleware is registered as a **named alias** (`locale`), not pushed onto the `web`
middleware group. Apply it explicitly to the route group(s) that make up your storefront:
```php
// routes/web.php
use Illuminate\Support\Facades\Route;
Route::middleware('locale')->group(function () {
Route::get('/{locale}', HomeController::class);
Route::get('/{locale}/proionta', ProductIndexController::class);
Route::get('/{locale}/proionta/{slug}', ProductShowController::class);
});
```
It is **not** applied automatically because storefront routes aren't the only routes living
under `web` in a shop:
- The Filament admin panel (`/boboko*`, see `PanelServiceProvider`) has its own routing/auth
concerns and must never be locale-redirected.
- Livewire's internal update endpoint (`/livewire/update`) must resolve without a locale prefix.
- Webhooks, health checks, and other non-storefront routes shouldn't be touched.
If a shop's entire `web.php` *is* the storefront, wrapping the whole file in the group above is
fine — just keep admin/Livewire/webhook routes registered outside of it (as they already are).
---
## Behavior
`Modules\Core\Localization\LocaleMiddleware`:
1. Reads the first path segment (`request()->segment(1)`).
2. Matches it against `Lunar\Models\Language::code`.
- **Match** — `App::setLocale($code)` is set, and `locale` / `language` request attributes
are populated for controllers/views to use.
- **No match** (missing, wrong, or unknown segment) — redirects to the same path prefixed
with a resolved locale:
- the best match from the `Accept-Language` header against available language codes, or
- the language flagged `default` in the `languages` table, or
- the first language row, as a last resort.
The language list is cached with `Cache::rememberForever()` under `core.localization.languages`
and invalidated automatically. Adding, editing, or removing a language via the Filament
**Languages** resource clears the cache immediately — no TTL, no stale reads.
### How invalidation is wired (event-driven, not the observer itself)
`Modules\Core\Localization\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
`code` — or `LanguageDeleted`), and two listeners, wired in
`Modules\Core\Providers\LocalizationServiceProvider`, react:
- **`FlushLanguageCache`** — flushes `core.localization.languages` on all three events.
- **`MigrateTranslationsForRenamedLanguage`** — `LanguageUpdated` only, and only when `code`
actually changed. A renamed `Language::code` (e.g. `el` → `gr`) would otherwise strand every
`LanguageLine`'s translated text under the old, now-unroutable key —
`getTranslationsForGroup('gr', ...)` would silently return nothing for that locale even though
the translated content still exists. This listener moves the `text.{oldCode}` key to
`text.{newCode}` on every affected `LanguageLine` row and flushes both the old and new code's
translation cache for every group touched.
Deleting a `Language` only flushes the language-list cache — `LanguageLine.text` keys for the
deleted code are left in place rather than destructively erased, in case the language is ever
re-added under the same code.
Splitting cache-flush and text-migration into separate listeners (rather than one
`LanguageCacheObserver` method doing both) mirrors the same event → listener pattern used for
`TranslationService`'s writes below — the observer only detects *what happened*, listeners own
*what to do about it*.
---
## Reading the resolved locale/language downstream
```php
// In a controller or view composer
$locale = $request->attributes->get('locale'); // e.g. "el"
$language = $request->attributes->get('language'); // Lunar\Models\Language instance
```
Use `$language->id` when querying Lunar's translatable content (e.g. `Url::where('language_id', ...)`).
---
## Single-language shops
If a shop has only one row in `languages`, the middleware still enforces the prefix (e.g. every
URL under `/en/...`) rather than special-casing it away — this keeps behavior identical across
shops and avoids a silent restructuring if a second language is added later. If a shop genuinely
never wants locale prefixes, don't apply the `locale` middleware to its routes at all.
---
## Storefront UI labels (`__('storefront.*')`)
The `locale` middleware resolves *which* language a request is in — routing/redirects,
`Lunar\Models\Language`, and Lunar's own translatable product/collection content. It has nothing
to do with static UI chrome like "Cart", "Back", "Add to Cart". Those are handled separately by
[`spatie/laravel-translation-loader`](https://github.com/spatie/laravel-translation-loader),
stored in the `language_lines` table.
### Why a separate system, not another `languages`-table lookup
Lunar's translatable fields (`TranslatedText`, `Url`, etc.) are all tied to specific *model
records* — a product's name, a collection's description. UI labels aren't attached to any model;
they're static strings the app itself owns. `laravel-translation-loader` is Laravel's own
`__()`/`trans()` mechanism with a DB-backed source layered on top of the normal file-based one —
no new helper to learn, no bespoke table shape.
**Nothing existing breaks.** The package's `TranslationLoaderManager` *extends* Laravel's
`FileLoader` and merges DB translations on top of file-based ones
(`array_replace_recursive()`) — Filament's own vendor `lang/en/product.php`-style strings
keep working exactly as before. The package registers itself via Laravel's standard Composer
package auto-discovery (`extra.laravel.providers` in its own `composer.json`) — nothing needed
in `CoreServiceProvider` to wire it up.
### Usage
```blade
{{ __('storefront.nav.cart') }}
{{ __('storefront.product.add_to_cart') }}
```
`group` is `storefront` for e-shop UI labels — kept separate from Lunar/Filament's own `lunar::`
namespaced groups so nothing collides. `__()` resolves the translation for whatever
`App::getLocale()` currently is, which `LocaleMiddleware` already sets per-request (see
"Behavior" above) — no extra wiring needed between the two systems.
### Seeding
A starter set of common e-shop labels (`nav.*`, `cart.*`, `product.*`, `auth.*`, `search.*`,
English + Greek) is seeded by `Modules\Core\Command\InstallLunarCommand` (overrides Lunar's own
`lunar:install`), guarded by `LanguageLine::where('group', 'storefront')->exists()` — same
idempotent pattern as the rest of that command, safe to run unattended on every boot.
### Admin UI
`Modules\Core\Localization\Filament\Resources\LanguageLineResource` (registered in
`CorePlugin`, under the panel's Settings group) lists/searches/filters `language_lines` and
edits each row's `group`, `key`, and one text input per row currently in `lunar_languages` —
the locale columns are generated dynamically from `Language::query()->pluck('code')`, so adding
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
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;
app(TranslationService::class)->create('storefront', 'nav.wishlist', [
'en' => 'Wishlist',
'el' => 'Λίστα Επιθυμιών',
]);
app(TranslationService::class)->update(
$languageLine,
'storefront',
'nav.wishlist',
['en' => 'Wishlist ♥', 'el' => 'Λίστα Επιθυμιών ♥'],
);
app(TranslationService::class)->delete($languageLine);
```
`update()` takes the full `group`/`key`/`text` state, not just `text` — a rename is a normal
update, not a special case. `TranslationCreated`, `TranslationUpdated` (carries the full
`{group, key, text}` snapshot from *before* the update, so a listener can tell a rename from a
text edit), and `TranslationDeleted` are dispatched from `Modules\Core\Localization\Events`. Two
listeners are wired in `Modules\Core\Providers\LocalizationServiceProvider` for all three events:
- **`FlushTranslationCache`** — `LanguageLine::boot()` already flushes the cache for the
*current* group's locales present after a save, but misses two cases on update: locales a save
*removed* from `text` (e.g. dropping the `el` key leaves `storefront.el` stale), and a changed
`group`/`key` (the *old* group's cached array is never told a row left it). This listener
flushes every group+locale combination touched by either the old or new state, so nothing —
including the group a row was renamed away from — can remain stale.
- **`LogTranslationActivity`** — records the change via `Modules\Core\Logging\ActivityLogService`
on the `lunar` activity log channel, same `created`/`updated`/`deleted` shape as every other
domain write in this project. A rename shows up in the log as an `old`/`attributes` diff across
`group`, `key`, and `text` together, not just a text diff.
The Filament resource's Create/Edit/Delete pages route through `TranslationService` (via
`handleRecordCreation`/`handleRecordUpdate`/the delete action's `->action()` override) rather
than Filament's default direct-model calls, so **every** edit made in the admin UI — including a
bare `group`/`key` rename with no `text` change — dispatches `TranslationUpdated` and is both
cache-invalidated and audit-logged.
+3
View File
@@ -1206,3 +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()`.
- **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
@@ -0,0 +1,383 @@
# 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.
+157
View File
@@ -0,0 +1,157 @@
# Product Listing
`Modules\Core\Catalog\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
handles free-text query search. `ProductService` is for browsing/lookup without a search term.
---
## Why it reads from the index, not the database
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
carry that full shape.
---
## Usage
```php
use Modules\Core\Catalog\ProductFilters;
use Modules\Core\Catalog\ProductService;
$service = app(ProductService::class);
// List everything, paginated
$result = $service->list(perPage: 24, page: 1);
// Filter by collection, brand, and/or price range
$result = $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'];
// Single product, by primary key
$product = $service->getById(367); // array, or null if not found
// Single product, by URL slug (any locale — slugs are indexed across all languages)
$product = $service->getBySlug('erotika-mprelok'); // array, or null if not found
```
All `ProductFilters` fields are optional; only the ones set are added to the Meilisearch query.
---
## Fields this depends on: `Modules\Core\Search\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`
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. |
| `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. |
`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.
**`ProductOption`/`ProductOptionValue` names need a different translation accessor.** Unlike
`Product`/`Collection`/`Brand`, their `name` is a plain locale-keyed array cast, not
`attribute_data` — Lunar's `translateAttribute('name')` silently returns `null` for them. The
indexer's `translatedName()` reads the array directly instead. See `docs/lunar.md` "Gotchas".
---
## 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.
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.
`Modules\Core\Providers\ReviewServiceProvider` listens on `ProductReview`'s `created`/`updated`/
`deleted` events and calls `$review->product->searchable()`, so the parent product's document
stays current without waiting for the next full reindex. This provider must be registered in
`composer.json`'s `extra.laravel.providers` (already done in this repo) — see `docs/modules.md`
"Provider Registration Pitfalls" for what happens if a provider like this is ever added but not
registered.
---
## Multi-variant products and price
A product's `price` is its *cheapest* variant's price ("from €19.99" style), not every variant's
price. A price-range filter matches based on that single minimum — a product with one cheap
variant and several expensive ones will match a low-price-range filter even though most of its
variants don't.
---
## 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,
// ...other model indexers unchanged
],
```
## Re-syncing after this change
Filterable attributes are Meilisearch index settings, not computed per-query — changing them
requires re-syncing settings and reindexing existing documents:
```bash
php artisan lunar:meilisearch:setup
php artisan lunar:search:index "Lunar\Models\Product" --refresh
```
**If `SCOUT_QUEUE=true`, restart the queue worker after deploying an indexer change.** A running
`queue:work` process loads PHP classes once at boot and keeps that code in memory for its entire
lifetime — it does not pick up an edited/newly-deployed indexer class. Symptoms: reindexing
commands succeed with no errors, `Product::toSearchableArray()` returns the new fields correctly
when called directly (e.g. via `artisan tinker`, which always boots fresh), but documents written
via `$model->searchable()` through the live queue are still missing the new fields. Restarting the
queue worker (`docker compose restart queue`, or equivalent) resolves it — no code change needed.
---
## Meilisearch driver quirk: `paginateRaw()`'s `items()` is not a list of hits
For the Meilisearch engine specifically, Scout's `Builder::paginateRaw()` puts the **entire raw
response** (`hits`, `query`, `processingTimeMs`, `hitsPerPage`, `page`, `totalPages`, `totalHits`)
into the paginator's `items()`, not a plain array of documents. Calling `$paginator->items()`
and treating it as a list (e.g. `collect($paginator->items())->values()`) silently produces a
7-element array whose first element happens to be the real hits and the rest are stray scalars
from the other response keys — no error, just wrong data leaking into what looks like a normal
list. `ProductService::list()` pulls `$paginator->items()['hits']` explicitly to avoid this;
`$paginator->total()`/`perPage()`/`currentPage()`/`lastPage()` are unaffected and safe to use
as-is.
+81
View File
@@ -0,0 +1,81 @@
# Product Search
`Modules\Core\Search\ProductSearchService` provides locale-aware full-text product search on
top of Laravel Scout + Meilisearch.
---
## Why locale-aware search isn't a filter
Lunar's Meilisearch indexer (`Lunar\Search\ScoutIndexer::mapSearchableAttributes()`) flattens
every translated attribute into **locale-suffixed fields on a single document** — a product with
a translated `name` produces `name_en`, `name_el`, etc. as separate top-level fields, not
separate documents per locale and not a filterable `locale` field.
That means "search in Greek" isn't a `->filter('locale = el')` — Meilisearch has no such field to
filter on. It's a choice of **which fields the query targets**: `name_el`/`description_el`
instead of `name_en`/`description_en`. This is what Meilisearch's `attributesToSearchOn` search
parameter controls, exposed through Scout via `Builder::options()`, which passes straight through
to the underlying Meilisearch client call (`Laravel\Scout\Engines\MeilisearchEngine::performSearch()`
merges `$builder->options` directly into the search request).
---
## Usage
```php
use Modules\Core\Search\ProductSearchService;
$results = app(ProductSearchService::class)->search('running shoes');
// or an explicit locale, bypassing App::getLocale():
$results = app(ProductSearchService::class)->search('running shoes', 'el');
```
Returns an `Illuminate\Database\Eloquent\Collection` of `Lunar\Models\Product` — Scout's
`->get()` hydrates real models from the database after the Meilisearch query, so relations
(`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
don't need to pass it explicitly.
---
## Missing-translation fallback
If a product was only ever given an English name, `name_el` doesn't exist on that document at
all (Lunar's indexer only writes a `{handle}_{locale}` field for locales actually present in the
attribute's stored data — see `ScoutIndexer::mapSearchableAttributes()`). Searching strictly
against `name_el` would make that product invisible to Greek-locale search, even though it's a
real catalog item.
To avoid silently hiding incompletely-translated products, `ProductSearchService` targets **both**
the resolved locale's fields **and** the default language's fields
(`Lunar\Models\Language::getDefault()->code`) — e.g. searching in `el` targets `name_el`,
`name_en`, `description_el`, `description_en` together (assuming `en` is the default language).
A product missing an `el` translation still matches via its `en` fields.
---
## Field list is dynamic, not hardcoded
The set of attribute handles searched (`name`, `description`, or whatever else) comes from
`Lunar\Facades\AttributeManifest::getSearchableAttributes(Product::morphName())` — the same
source `ScoutIndexer` itself uses to decide what gets indexed. If an admin marks a new attribute
searchable in the panel, `ProductSearchService` picks it up automatically; nothing in this class
needs to change.
---
## Re-syncing after indexer changes
Changing which attributes are searchable, or `ProductIndexer`'s filterable/sortable fields,
requires re-syncing Meilisearch's index settings and re-indexing existing documents:
```bash
php artisan lunar:meilisearch:setup
php artisan lunar:search:index "Lunar\Models\Product" --refresh
```
`ProductSearchService` itself needs no re-sync when locales change — `attributesToSearchOn` is
computed per-query from the live language list, not baked into index settings.
@@ -46,6 +46,10 @@
<x-filament::button type="submit" class="w-full">
Sign in
</x-filament::button>
<x-filament::link wire:click="back" tag="button" type="button" class="mx-auto">
&larr; Back
</x-filament::link>
</div>
</form>
@endif
+25
View File
@@ -0,0 +1,25 @@
<?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,
) {}
}
+6
View File
@@ -35,6 +35,12 @@ class Login extends SimplePage
}
}
public function back(): void
{
$this->otpSent = false;
$this->otp = '';
}
public function requestOtp(): void
{
$this->validate(['email' => 'required|email']);
+4
View File
@@ -2,7 +2,9 @@
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
@@ -43,6 +45,8 @@ class UserOtpService
$user->otp_expires_at = null;
$user->save();
Event::dispatch(new UserAuthenticated($user));
return $user;
}
}
+20
View File
@@ -0,0 +1,20 @@
<?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
@@ -0,0 +1,126 @@
<?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 ');
}
}
+35
View File
@@ -18,6 +18,7 @@ use Lunar\Models\Product;
use Lunar\Models\ProductType;
use Lunar\Models\TaxClass;
use Lunar\Models\TaxZone;
use Spatie\TranslationLoader\LanguageLine;
/**
* Overrides Lunar's own lunar:install to skip the interactive prompts (migrate
@@ -241,9 +242,43 @@ class InstallLunarCommand extends Command
}
});
if (! LanguageLine::where('group', 'storefront')->exists()) {
$this->components->info('Seeding storefront label translations');
$this->seedStorefrontLabels();
}
$this->components->info('Publishing Filament assets');
$this->call('filament:assets');
$this->components->info('Lunar default data seeded.');
}
private function seedStorefrontLabels(): void
{
$labels = [
'nav.home' => ['en' => 'Home', 'el' => 'Αρχική'],
'nav.products' => ['en' => 'Products', 'el' => 'Προϊόντα'],
'nav.cart' => ['en' => 'Cart', 'el' => 'Καλάθι'],
'nav.account' => ['en' => 'Account', 'el' => 'Λογαριασμός'],
'nav.back' => ['en' => 'Back', 'el' => 'Πίσω'],
'cart.empty' => ['en' => 'Your cart is empty', 'el' => 'Το καλάθι σας είναι άδειο'],
'cart.checkout' => ['en' => 'Checkout', 'el' => 'Ολοκλήρωση Παραγγελίας'],
'cart.total' => ['en' => 'Total', 'el' => 'Σύνολο'],
'cart.remove' => ['en' => 'Remove', 'el' => 'Αφαίρεση'],
'product.add_to_cart' => ['en' => 'Add to Cart', 'el' => 'Προσθήκη στο Καλάθι'],
'product.out_of_stock' => ['en' => 'Out of Stock', 'el' => 'Εξαντλήθηκε'],
'product.price' => ['en' => 'Price', 'el' => 'Τιμή'],
'auth.login' => ['en' => 'Log In', 'el' => 'Σύνδεση'],
'auth.logout' => ['en' => 'Log Out', 'el' => 'Αποσύνδεση'],
'search.placeholder' => ['en' => 'Search products…', 'el' => 'Αναζήτηση προϊόντων…'],
];
foreach ($labels as $key => $text) {
LanguageLine::create([
'group' => 'storefront',
'key' => $key,
'text' => $text,
]);
}
}
}
@@ -0,0 +1,47 @@
<?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).');
}
}
+4
View File
@@ -15,6 +15,7 @@ use Lunar\Shipping\ShippingPlugin;
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\Review\Extensions\ProductResourceExtension;
use Modules\Core\Review\Models\ProductReview;
@@ -32,6 +33,9 @@ class CorePlugin implements Plugin
->brandLogo(asset('static/logos/core/boboko-logo.svg'))
->darkModeBrandLogo(asset('static/logos/core/boboko-logo-white.svg'))
->login(Login::class)
->resources([
LanguageLineResource::class,
])
->plugin(ShippingPlugin::make());
LunarPanel::extensions([
+21
View File
@@ -0,0 +1,21 @@
<?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
@@ -0,0 +1,45 @@
<?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;
}
}
@@ -0,0 +1,12 @@
<?php
namespace Modules\Core\Localization\Events;
use Lunar\Models\Language;
class LanguageCreated
{
public function __construct(
public readonly Language $language,
) {}
}
@@ -0,0 +1,12 @@
<?php
namespace Modules\Core\Localization\Events;
use Lunar\Models\Language;
class LanguageDeleted
{
public function __construct(
public readonly Language $language,
) {}
}
@@ -0,0 +1,16 @@
<?php
namespace Modules\Core\Localization\Events;
use Lunar\Models\Language;
class LanguageUpdated
{
/**
* @param array{code: string} $old Snapshot of watched attributes before the update.
*/
public function __construct(
public readonly Language $language,
public readonly array $old,
) {}
}
@@ -0,0 +1,12 @@
<?php
namespace Modules\Core\Localization\Events;
use Spatie\TranslationLoader\LanguageLine;
class TranslationCreated
{
public function __construct(
public readonly LanguageLine $languageLine,
) {}
}
@@ -0,0 +1,12 @@
<?php
namespace Modules\Core\Localization\Events;
use Spatie\TranslationLoader\LanguageLine;
class TranslationDeleted
{
public function __construct(
public readonly LanguageLine $languageLine,
) {}
}
@@ -0,0 +1,16 @@
<?php
namespace Modules\Core\Localization\Events;
use Spatie\TranslationLoader\LanguageLine;
class TranslationUpdated
{
/**
* @param array{group: string, key: string, text: array} $old Snapshot before the update.
*/
public function __construct(
public readonly LanguageLine $languageLine,
public readonly array $old,
) {}
}
@@ -0,0 +1,107 @@
<?php
namespace Modules\Core\Localization\Filament\Resources;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Lunar\Models\Language;
use Modules\Core\Localization\Filament\Resources\LanguageLineResource\Pages;
use Spatie\TranslationLoader\LanguageLine;
class LanguageLineResource extends Resource
{
protected static ?string $model = LanguageLine::class;
protected static ?string $navigationIcon = 'heroicon-o-language';
protected static ?string $navigationGroup = 'Settings';
protected static ?string $modelLabel = 'Translation';
protected static ?string $pluralModelLabel = 'Translations';
public static function form(Form $form): Form
{
return $form->schema([
Forms\Components\TextInput::make('group')
->required()
->maxLength(255)
->default('storefront')
->helperText('Namespace for this label, e.g. "storefront" for e-shop UI text.'),
Forms\Components\TextInput::make('key')
->required()
->maxLength(255)
->helperText('Dot-notation key, e.g. "nav.cart".'),
Forms\Components\Fieldset::make('Translations')
->schema(static::localeInputs()),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('group')
->badge()
->sortable(),
Tables\Columns\TextColumn::make('key')
->searchable()
->sortable(),
...static::localeColumns(),
])
->filters([
Tables\Filters\SelectFilter::make('group')
->options(fn () => LanguageLine::query()->distinct()->pluck('group', 'group')),
])
->defaultSort('key');
}
public static function getRelations(): array
{
return [];
}
public static function getPages(): array
{
return [
'index' => Pages\ListLanguageLines::route('/'),
'create' => Pages\CreateLanguageLine::route('/create'),
'edit' => Pages\EditLanguageLine::route('/{record}/edit'),
];
}
/**
* @return array<Forms\Components\Textarea>
*/
private static function localeInputs(): array
{
return static::localeCodes()
->map(fn (string $code) => Forms\Components\Textarea::make("text.{$code}")
->label(strtoupper($code))
->rows(2))
->all();
}
/**
* @return array<Tables\Columns\TextColumn>
*/
private static function localeColumns(): array
{
return static::localeCodes()
->map(fn (string $code) => Tables\Columns\TextColumn::make("text.{$code}")
->label(strtoupper($code))
->limit(40)
->toggleable())
->all();
}
private static function localeCodes(): \Illuminate\Support\Collection
{
return Language::query()->pluck('code');
}
}
@@ -0,0 +1,22 @@
<?php
namespace Modules\Core\Localization\Filament\Resources\LanguageLineResource\Pages;
use Filament\Resources\Pages\CreateRecord;
use Illuminate\Database\Eloquent\Model;
use Modules\Core\Localization\Filament\Resources\LanguageLineResource;
use Modules\Core\Localization\TranslationService;
class CreateLanguageLine extends CreateRecord
{
protected static string $resource = LanguageLineResource::class;
protected function handleRecordCreation(array $data): Model
{
return app(TranslationService::class)->create(
$data['group'],
$data['key'],
$data['text'] ?? [],
);
}
}
@@ -0,0 +1,53 @@
<?php
namespace Modules\Core\Localization\Filament\Resources\LanguageLineResource\Pages;
use Filament\Actions;
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 Spatie\TranslationLoader\LanguageLine;
class EditLanguageLine extends EditRecord
{
protected static string $resource = LanguageLineResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make()
->action(function (LanguageLine $record) {
app(TranslationService::class)->delete($record);
$this->redirect($this->getResource()::getUrl('index'));
}),
];
}
/**
* Filament's default Cancel button uses window.history.back(), which
* restores the browser's cached previous page instead of re-fetching —
* so an edit made just before clicking Cancel doesn't show up in the
* list until a manual refresh. Redirect through Livewire instead, which
* always re-queries.
*/
protected function getCancelFormAction(): Action
{
return Action::make('cancel')
->label(__('filament-panels::resources/pages/edit-record.form.actions.cancel.label'))
->url(static::getResource()::getUrl('index'))
->color('gray');
}
protected function handleRecordUpdate(Model $record, array $data): Model
{
return app(TranslationService::class)->update(
$record,
$data['group'],
$data['key'],
$data['text'] ?? [],
);
}
}
@@ -0,0 +1,19 @@
<?php
namespace Modules\Core\Localization\Filament\Resources\LanguageLineResource\Pages;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
use Modules\Core\Localization\Filament\Resources\LanguageLineResource;
class ListLanguageLines extends ListRecords
{
protected static string $resource = LanguageLineResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}
@@ -0,0 +1,29 @@
<?php
namespace Modules\Core\Localization;
use Illuminate\Support\Facades\Event;
use Lunar\Models\Language;
use Modules\Core\Localization\Events\LanguageCreated;
use Modules\Core\Localization\Events\LanguageDeleted;
use Modules\Core\Localization\Events\LanguageUpdated;
class LanguageCacheObserver
{
public function created(Language $language): void
{
Event::dispatch(new LanguageCreated($language));
}
public function updated(Language $language): void
{
Event::dispatch(new LanguageUpdated($language, [
'code' => $language->getOriginal('code'),
]));
}
public function deleted(Language $language): void
{
Event::dispatch(new LanguageDeleted($language));
}
}
@@ -0,0 +1,16 @@
<?php
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;
class FlushLanguageCache
{
public function handle(LanguageCreated|LanguageUpdated|LanguageDeleted $event): void
{
LocaleMiddleware::forgetLanguagesCache();
}
}
@@ -0,0 +1,36 @@
<?php
namespace Modules\Core\Localization\Listeners;
use Illuminate\Support\Facades\Cache;
use Modules\Core\Localization\Events\TranslationCreated;
use Modules\Core\Localization\Events\TranslationDeleted;
use Modules\Core\Localization\Events\TranslationUpdated;
use Spatie\TranslationLoader\LanguageLine;
/**
* LanguageLine::boot() already flushes the cache for the current group's locales
* present after a save, but misses two cases on update: locales removed from
* `text` (e.g. dropping the "el" key leaves `{group}.el` stale), and a changed
* `group`/`key` (the old group's cached array never gets told a row left it).
* This listener flushes every group+locale combination touched by either the
* old or new state so nothing can remain stale.
*/
class FlushTranslationCache
{
public function handle(TranslationCreated|TranslationUpdated|TranslationDeleted $event): void
{
$this->flush($event->languageLine->group, array_keys($event->languageLine->text ?? []));
if ($event instanceof TranslationUpdated) {
$this->flush($event->old['group'], array_keys($event->old['text'] ?? []));
}
}
private function flush(string $group, array $locales): void
{
foreach ($locales as $locale) {
Cache::forget(LanguageLine::getCacheKey($group, $locale));
}
}
}
@@ -0,0 +1,53 @@
<?php
namespace Modules\Core\Localization\Listeners;
use Illuminate\Support\Arr;
use Modules\Core\Localization\Events\TranslationCreated;
use Modules\Core\Localization\Events\TranslationDeleted;
use Modules\Core\Localization\Events\TranslationUpdated;
use Modules\Core\Logging\ActivityLogService;
use Spatie\TranslationLoader\LanguageLine;
class LogTranslationActivity
{
public function __construct(
private readonly ActivityLogService $activityLog,
) {}
public function handle(TranslationCreated|TranslationUpdated|TranslationDeleted $event): void
{
$languageLine = $event->languageLine;
match (true) {
$event instanceof TranslationCreated => $this->activityLog->created(
$languageLine,
$this->flatten($languageLine),
),
$event instanceof TranslationUpdated => $this->activityLog->updated(
$languageLine,
Arr::dot($event->old),
$this->flatten($languageLine),
),
$event instanceof TranslationDeleted => $this->activityLog->deleted(
$languageLine,
$this->flatten($languageLine),
),
};
}
/**
* Filament's Activity resource renders `properties` with a flat KeyValue
* field, which can't display a nested value like `text: {en, el}` — it
* shows as "[object Object]". Flatten to dot-notation ("text.en",
* "text.el") so every property is a plain string, viewable as-is.
*/
private function flatten(LanguageLine $languageLine): array
{
return Arr::dot([
'group' => $languageLine->group,
'key' => $languageLine->key,
'text' => $languageLine->text,
]);
}
}
@@ -0,0 +1,46 @@
<?php
namespace Modules\Core\Localization\Listeners;
use Illuminate\Support\Facades\Cache;
use Modules\Core\Localization\Events\LanguageUpdated;
use Spatie\TranslationLoader\LanguageLine;
/**
* A renamed Language::code (e.g. "el" -> "gr") would otherwise strand every
* LanguageLine's translated text under the old, now-unroutable key —
* getTranslationsForGroup($newCode, ...) would silently return nothing for
* that locale even though the translated content still exists. Move the
* text.{oldCode} key to text.{newCode} on every affected row instead.
*/
class MigrateTranslationsForRenamedLanguage
{
public function handle(LanguageUpdated $event): void
{
$oldCode = $event->old['code'];
$newCode = $event->language->code;
if ($oldCode === $newCode) {
return;
}
$affectedGroups = [];
LanguageLine::query()
->whereJsonContainsKey('text->'.$oldCode)
->each(function (LanguageLine $languageLine) use ($oldCode, $newCode, &$affectedGroups) {
$text = $languageLine->text;
$text[$newCode] = $text[$oldCode];
unset($text[$oldCode]);
$languageLine->update(['text' => $text]);
$affectedGroups[$languageLine->group] = true;
});
foreach (array_keys($affectedGroups) as $group) {
Cache::forget(LanguageLine::getCacheKey($group, $oldCode));
Cache::forget(LanguageLine::getCacheKey($group, $newCode));
}
}
}
+119
View File
@@ -0,0 +1,119 @@
<?php
namespace Modules\Core\Localization;
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 Symfony\Component\HttpFoundation\Response;
class LocaleMiddleware
{
private const CACHE_KEY = 'core.localization.languages';
public function handle(Request $request, Closure $next): Response
{
$languages = $this->availableLanguages();
if ($languages->isEmpty()) {
return $next($request);
}
$segment = (string) $request->segment(1);
$language = $languages->firstWhere('code', $segment);
if (! $language) {
return $this->redirectToLocalizedUrl($request, $languages);
}
App::setLocale($language->code);
$request->attributes->set('locale', $language->code);
$request->attributes->set('language', $language);
// Lets route() calls omit {locale} anywhere in the request lifecycle
// (controllers, views) — without this, every route() call would need
// locale passed explicitly every time.
URL::defaults(['locale' => $language->code]);
$this->shareLocaleViewData($request, $language, $languages);
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.
*/
private function shareLocaleViewData(Request $request, Language $language, Collection $languages): void
{
$altLanguage = $languages->firstWhere('code', '!=', $language->code);
$route = $request->route();
$routeName = $route?->getName();
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),
);
}
private function redirectToLocalizedUrl(Request $request, Collection $languages): Response
{
$locale = $this->negotiateLocale($request, $languages);
$path = trim($request->getPathInfo(), '/');
$target = '/'.$locale.($path !== '' ? '/'.$path : '');
$query = $request->getQueryString();
if ($query) {
$target .= '?'.$query;
}
return redirect($target);
}
private function negotiateLocale(Request $request, Collection $languages): string
{
$preferred = $request->getPreferredLanguage($languages->pluck('code')->all());
if ($preferred) {
return $preferred;
}
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']),
);
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace Modules\Core\Localization;
use Illuminate\Support\Facades\App;
use Spatie\TranslationLoader\LanguageLine;
class TranslationReader
{
private const DEFAULT_GROUP = 'storefront';
/**
* All labels in a group for the given (or current) locale, keyed by their
* dot-notation key — e.g. ['nav.cart' => 'Cart', 'nav.home' => 'Home'].
* Backed by LanguageLine's own forever-cache, so this is a cache hit after
* the first call for a given group+locale.
*/
public function group(string $group = self::DEFAULT_GROUP, ?string $locale = null): array
{
return LanguageLine::getTranslationsForGroup($locale ?? App::getLocale(), $group);
}
}
+57
View File
@@ -0,0 +1,57 @@
<?php
namespace Modules\Core\Localization;
use Illuminate\Support\Facades\Event;
use Modules\Core\Localization\Events\TranslationCreated;
use Modules\Core\Localization\Events\TranslationDeleted;
use Modules\Core\Localization\Events\TranslationUpdated;
use Spatie\TranslationLoader\LanguageLine;
class TranslationService
{
public function create(string $group, string $key, array $text): LanguageLine
{
$languageLine = LanguageLine::create([
'group' => $group,
'key' => $key,
'text' => $text,
]);
Event::dispatch(new TranslationCreated($languageLine));
return $languageLine;
}
public function update(LanguageLine $languageLine, string $group, string $key, array $text): LanguageLine
{
// Callers (e.g. Filament's EditRecord) may hand us a model instance
// already filled with the new form values in memory — refresh from the
// database first so $old reflects what's actually persisted, not what's
// about to be written.
$persisted = $languageLine->fresh();
$old = [
'group' => $persisted->group,
'key' => $persisted->key,
'text' => $persisted->text,
];
$languageLine->update([
'group' => $group,
'key' => $key,
'text' => $text,
]);
Event::dispatch(new TranslationUpdated($languageLine, $old));
return $languageLine;
}
public function delete(LanguageLine $languageLine): void
{
$languageLine->delete();
Event::dispatch(new TranslationDeleted($languageLine));
}
}
@@ -0,0 +1,55 @@
<?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
@@ -0,0 +1,28 @@
<?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
@@ -0,0 +1,16 @@
<?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
@@ -0,0 +1,31 @@
<?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
@@ -0,0 +1,10 @@
<?php
namespace Modules\Core\Privacy;
enum ErasureRequestStatus: string
{
case Pending = 'pending';
case Cancelled = 'cancelled';
case Completed = 'completed';
}
@@ -0,0 +1,20 @@
<?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,
) {}
}
@@ -0,0 +1,22 @@
<?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,
) {}
}
@@ -0,0 +1,19 @@
<?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
@@ -0,0 +1,33 @@
<?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
@@ -0,0 +1,10 @@
<?php
namespace Modules\Core\Privacy;
enum ExportRequestStatus: string
{
case Pending = 'pending';
case Completed = 'completed';
case Failed = 'failed';
}
+36
View File
@@ -0,0 +1,36 @@
<?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
@@ -0,0 +1,67 @@
<?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]);
}
}
@@ -0,0 +1,61 @@
<?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);
}
}
}
@@ -0,0 +1,64 @@
<?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;
}
}
@@ -0,0 +1,92 @@
<?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
@@ -0,0 +1,82 @@
<?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
@@ -0,0 +1,37 @@
<?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
@@ -0,0 +1,50 @@
<?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
@@ -0,0 +1,279 @@
<?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
@@ -0,0 +1,18 @@
<?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
@@ -0,0 +1,19 @@
<?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,
) {}
}
@@ -0,0 +1,63 @@
<?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.');
}
}
@@ -0,0 +1,62 @@
<?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.');
}
}
@@ -0,0 +1,115 @@
<?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);
}
}
@@ -0,0 +1,97 @@
<?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.');
}
}
@@ -0,0 +1,99 @@
<?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
@@ -0,0 +1,30 @@
<?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);
}
}
+4 -2
View File
@@ -10,6 +10,7 @@ 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
{
@@ -26,6 +27,7 @@ class CoreServiceProvider extends ServiceProvider
$this->publishes([
__DIR__ . '/../../config/core.php' => config_path('core.php'),
__DIR__ . '/../../config/scout.php' => config_path('scout.php'),
], 'core-config');
$this->publishes([
@@ -34,10 +36,10 @@ class CoreServiceProvider extends ServiceProvider
], 'core-assets');
if ($this->app->runningInConsole()) {
$this->commands([AnonymizeCommand::class, ExportCommand::class, ExportCleanupCommand::class, ImportCommand::class, MigrateImportCommand::class]);
$this->commands([AnonymizeCommand::class, ExportCommand::class, ExportCleanupCommand::class, ImportCommand::class, MigrateImportCommand::class, ProcessErasureRequestsCommand::class]);
//Overriding lunar:install
$this->app->booted(fn () => $this->commands([InstallLunarCommand::class]));
$this->app->booted(fn() => $this->commands([InstallLunarCommand::class]));
}
}
}
@@ -0,0 +1,39 @@
<?php
namespace Modules\Core\Providers;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
use Lunar\Models\Language;
use Modules\Core\Localization\Events\LanguageCreated;
use Modules\Core\Localization\Events\LanguageDeleted;
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;
class LocalizationServiceProvider extends ServiceProvider
{
public function boot(): void
{
$this->app['router']->aliasMiddleware('locale', LocaleMiddleware::class);
Language::observe(LanguageCacheObserver::class);
foreach ([TranslationCreated::class, TranslationUpdated::class, TranslationDeleted::class] as $event) {
Event::listen($event, FlushTranslationCache::class);
Event::listen($event, LogTranslationActivity::class);
}
foreach ([LanguageCreated::class, LanguageUpdated::class, LanguageDeleted::class] as $event) {
Event::listen($event, FlushLanguageCache::class);
}
Event::listen(LanguageUpdated::class, MigrateTranslationsForRenamedLanguage::class);
}
}
+22
View File
@@ -0,0 +1,22 @@
<?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);
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace Modules\Core\Providers;
use Illuminate\Support\ServiceProvider;
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.
*/
class ReviewServiceProvider extends ServiceProvider
{
public function boot(): void
{
ProductReview::created(fn (ProductReview $review) => $review->product?->searchable());
ProductReview::updated(fn (ProductReview $review) => $review->product?->searchable());
ProductReview::deleted(fn (ProductReview $review) => $review->product?->searchable());
}
}
+177
View File
@@ -0,0 +1,177 @@
<?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
@@ -0,0 +1,56 @@
<?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();
}
}