commit 9f58a36c824168803964b1b5ff31aaa02d85469e Author: Konstantinos Arvanitakis Date: Wed Jul 1 18:35:39 2026 +0300 Init diff --git a/README.md b/README.md new file mode 100644 index 0000000..c1041f5 --- /dev/null +++ b/README.md @@ -0,0 +1,124 @@ +# 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. + +--- + +## What's Inside + +### 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. + +See [`docs/otp-auth.md`](docs/otp-auth.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. + +**Creating a notification:** + +```php +class MyNotification extends BaseNotification +{ + public function __construct(private readonly MyEvent $event) {} + + public static function getKey(): string { return 'my.notification.key'; } + public static function listensTo(): string { return MyEvent::class; } + public function via(object $notifiable): array { return ['mail']; } + public function notifiable(): AnonymousNotifiable { ... } +} + +// Register in a service provider: +NotificationRegistry::get()->register([MyNotification::class]); +``` + +### 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. + +See [`docs/activity-log.md`](docs/activity-log.md). + +### Lunar Panel Integration + +- 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 + +Register the plugin in your Lunar panel provider: + +```php +->plugin(\Modules\Core\CorePlugin::make()) +``` + +### 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 | + +### Functional Types + +Result and Option monads for explicit error handling without exceptions. + +```php +// Result +$result = Success::of($value); +$result = Error::of('something went wrong'); +$result->map(fn($v) => ...)->flatMap(fn($v) => ...); + +// Option +$option = Option::fromValue($nullableValue); +$option->getOrElse('default'); +$option->map(fn($v) => ...)->filter(fn($v) => $v > 0); +``` + +--- + +## Installation + +Add the repository to your project's `composer.json`: + +```json +{ + "repositories": [ + { + "type": "vcs", + "url": "https://code.radical-elements.com/boboko/core.git" + } + ], + "require": { + "boboko/core": "^1.0" + } +} +``` + +Then run: + +```bash +composer require radical-elements/core +php artisan vendor:publish --tag=core-assets +php artisan migrate +``` + +--- + +## Requirements + +- PHP 8.2+ +- Laravel 11+ +- Lunar (lunarphp/lunar + lunarphp/admin) +- Spatie Laravel Activity Log + +--- + +## Documentation + +- [`docs/otp-auth.md`](docs/otp-auth.md) — OTP authentication flow +- [`docs/activity-log.md`](docs/activity-log.md) — Activity logging +- [`docs/lunar.md`](docs/lunar.md) — Lunar framework reference diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..930488e --- /dev/null +++ b/composer.json @@ -0,0 +1,18 @@ +{ + "name": "boboko/core", + "description": "Core module — authentication and shared panel behaviour", + "type": "library", + "version": "1.0.0", + "autoload": { + "psr-4": { + "Modules\\Core\\": "src/" + } + }, + "extra": { + "laravel": { + "providers": [ + "Modules\\Core\\Providers\\CoreServiceProvider" + ] + } + } +} diff --git a/database/migrations/2026_05_06_000001_add_otp_to_lunar_staff_table.php b/database/migrations/2026_05_06_000001_add_otp_to_lunar_staff_table.php new file mode 100644 index 0000000..7553a02 --- /dev/null +++ b/database/migrations/2026_05_06_000001_add_otp_to_lunar_staff_table.php @@ -0,0 +1,23 @@ +string('otp_code', 6)->nullable()->after('password'); + $table->timestamp('otp_expires_at')->nullable()->after('otp_code'); + }); + } + + public function down(): void + { + Schema::table('lunar_staff', function (Blueprint $table) { + $table->dropColumn(['otp_code', 'otp_expires_at']); + }); + } +}; diff --git a/database/migrations/2026_05_15_000001_make_password_nullable_on_lunar_staff_table.php b/database/migrations/2026_05_15_000001_make_password_nullable_on_lunar_staff_table.php new file mode 100644 index 0000000..1f14428 --- /dev/null +++ b/database/migrations/2026_05_15_000001_make_password_nullable_on_lunar_staff_table.php @@ -0,0 +1,22 @@ +string('password')->nullable()->change(); + }); + } + + public function down(): void + { + Schema::table('lunar_staff', function (Blueprint $table) { + $table->string('password')->nullable(false)->change(); + }); + } +}; diff --git a/docs/activity-log.md b/docs/activity-log.md new file mode 100644 index 0000000..02a72dc --- /dev/null +++ b/docs/activity-log.md @@ -0,0 +1,100 @@ +# Activity Log + +All domain write operations are logged through `ActivityLogService`, which wraps [Spatie Laravel Activity Log](https://spatie.be/docs/laravel-activitylog). + +--- + +## Channel + +All events are written to the `lunar` log channel. Query them with: + +```php +use Spatie\Activitylog\Models\Activity; + +Activity::on($subject)->get(); +Activity::causedBy($staff)->get(); +Activity::inLog('lunar')->get(); +``` + +--- + +## Usage + +Inject `ActivityLogService` and call the relevant method after each operation: + +```php +use App\Services\Logging\ActivityLogService; + +public function __construct(private ActivityLogService $activityLog) {} + +// Creation +$this->activityLog->created($model, ['name' => $name, 'type' => $type]); + +// Update — pass old state and new state separately +$this->activityLog->updated($model, ['name' => $old], ['name' => $new]); + +// Deletion +$this->activityLog->deleted($model, ['name' => $model->name, 'reason' => $reason]); +``` + +The actor is always resolved from `auth('staff')->user()` at call time — do not pass it explicitly. + +--- + +## Properties Shape + +| Method | `properties` key | Contents | +|---|---|---| +| `created` | `attributes` | New state snapshot | +| `updated` | `old` + `attributes` | Previous state and new state | +| `deleted` | `attributes` | Context at time of deletion (name, reason, etc.) | +| `failed` | `attributes` | Context at time of failure (service, error message, etc.) | + +--- + +## What Gets Logged + +### Appointments + +| Action | Method | Key properties | +|---|---|---| +| Book | `created` | `staff_id`, `date`, `start_time`, `end_time`, `customer` | +| Cancel | `deleted` | `date`, `reason` | +| Reassign | `updated` | old `staff` id → new `staff` id | +| Reschedule | `updated` | old `date`/`start_time`/`end_time` → new values | + +### Customers + +| Action | Method | Key properties | +|---|---|---| +| Elorus sync success | `updated` | `elorus_id`, `elorus_synced_at` | +| Elorus sync failure | `failed` | `service: elorus`, `error` | + +### Availability + +| Action | Method | Key properties | +|---|---|---| +| Create schedule | `created` | `name`, `frequency`, `type` | +| Create one-day schedule | `created` | `name`, `type`, `date` | +| Update schedule | `updated` | old name/frequency/type → new values | +| Delete schedule | `deleted` | `name` | + +--- + +## Adding New Events + +Follow the existing pattern — log immediately after the operation succeeds, before returning: + +```php +$record = $this->doSomething(); +$this->activityLog->created($record, ['relevant' => 'context']); +return $record; +``` + +For updates, capture the old state **before** mutating: + +```php +$old = ['name' => $record->name]; +$record->update(['name' => $newName]); +$this->activityLog->updated($record, $old, ['name' => $newName]); +``` diff --git a/docs/lunar.md b/docs/lunar.md new file mode 100644 index 0000000..58ce4d8 --- /dev/null +++ b/docs/lunar.md @@ -0,0 +1,1193 @@ +# Lunar Reference + +Lunar is a headless e-commerce package for Laravel (`lunarphp/core` + `lunarphp/lunar`). It handles products, carts, orders, payments, discounts, taxation, and more while leaving full control over the storefront UI. The admin panel is built on Filament. + +--- + +## Table of Contents + +1. [Core Concepts](#core-concepts) +2. [Key Models and Relationships](#key-models-and-relationships) +3. [ModelManifest — Replacing Models](#modelmanifest--replacing-models) +4. [Filament Panel Integration](#filament-panel-integration) +5. [Extending Resources and Pages](#extending-resources-and-pages) +6. [Activity Logging](#activity-logging) +7. [Pricing](#pricing) +8. [Tax](#tax) +9. [Channels and Currencies](#channels-and-currencies) +10. [Cart and Checkout](#cart-and-checkout) +11. [Orders](#orders) +12. [Customers and Addresses](#customers-and-addresses) +13. [Discounts](#discounts) +14. [Products and Variants](#products-and-variants) +15. [Collections and URLs](#collections-and-urls) +16. [Payments](#payments) +17. [Shipping](#shipping) +18. [Search Indexing](#search-indexing) +19. [Access Control](#access-control) + +--- + +## Core Concepts + +- **Headless**: Lunar is a Laravel package, not a standalone app. It lives inside your Laravel application. +- **No storefront UI prescribed**: The admin panel uses Filament/Livewire; the storefront can be Blade, Livewire, Inertia/Vue, or a pure API. +- **Table prefix**: All Lunar database tables use the `lunar_` prefix by default (configurable in `config/lunar/database.php`). +- **Prices stored as integers**: All monetary values are stored in the currency's smallest unit (e.g. cents). They are cast to `Lunar\DataTypes\Price` objects on retrieval. +- **Polymorphic morph map**: Lunar uses string aliases (e.g. `product`, `customer`) for polymorphic relations — not fully-qualified class names. +- **Config files**: Published to `config/lunar/` — key files are `database.php`, `cart.php`, `orders.php`, `taxes.php`, `payments.php`, `pricing.php`, `urls.php`, `media.php`. + +### LunarUser trait + +Add to your `User` model to gain relationships to carts, orders, and customers: + +```php +use Lunar\Base\Traits\LunarUser; +use Lunar\Base\LunarUser as LunarUserInterface; + +class User extends Authenticatable implements LunarUserInterface +{ + use LunarUser; +} +``` + +This adds: `customers` (BelongsToMany), `carts` (HasMany), `orders` (HasMany), and `latestCustomer()`. + +--- + +## Key Models and Relationships + +### Quick map + +| Model | Namespace | Notes | +|---|---|---| +| Product | `Lunar\Models\Product` | Has many variants; belongs to ProductType and optionally Brand | +| ProductVariant | `Lunar\Models\ProductVariant` | The actual purchasable item; has prices, stock, SKU | +| ProductType | `Lunar\Models\ProductType` | Defines which attributes a product has | +| ProductOption | `Lunar\Models\ProductOption` | e.g. "Size", "Color" | +| ProductOptionValue | `Lunar\Models\ProductOptionValue` | e.g. "Red", "Large" | +| Collection | `Lunar\Models\Collection` | Nested-set tree; belongs to CollectionGroup | +| CollectionGroup | `Lunar\Models\CollectionGroup` | Top-level grouping of collections | +| Cart | `Lunar\Models\Cart` | Has many CartLines; belongs to Currency and Channel | +| CartLine | `Lunar\Models\CartLine` | Polymorphic purchasable (usually ProductVariant) + quantity | +| CartAddress | `Lunar\Models\CartAddress` | Shipping or billing address on a cart | +| Order | `Lunar\Models\Order` | Created from a cart; has OrderLines and OrderAddresses | +| OrderLine | `Lunar\Models\OrderLine` | Snapshot of a purchased item | +| OrderAddress | `Lunar\Models\OrderAddress` | Immutable snapshot of shipping/billing address | +| Transaction | `Lunar\Models\Transaction` | Payment record (intent, capture, refund) | +| Customer | `Lunar\Models\Customer` | Buyer; linked to User via `customer_user` pivot | +| Address | `Lunar\Models\Address` | Saved address belonging to a Customer | +| Price | `Lunar\Models\Price` | Polymorphic; supports customer groups and quantity tiers | +| Currency | `Lunar\Models\Currency` | ISO 4217 code; exchange rate relative to default | +| Channel | `Lunar\Models\Channel` | Sales outlet; products are published per channel | +| Language | `Lunar\Models\Language` | ISO 639-1 code; controls translated attribute data | +| TaxClass | `Lunar\Models\TaxClass` | Assigned to variants; determines which rate applies | +| TaxZone | `Lunar\Models\TaxZone` | Geographic region; `tax_inclusive` or `tax_exclusive` | +| TaxRate | `Lunar\Models\TaxRate` | Belongs to TaxZone; has many TaxRateAmounts | +| Discount | `Lunar\Models\Discount` | Coupon/promo; AmountOff or BuyXGetY | +| Tag | `Lunar\Models\Tag` | Polymorphic tagging | +| Url | `Lunar\Models\Url` | SEO slug; polymorphic; one default per language | +| Brand | `Lunar\Models\Brand` | Optional brand for products | + +### Product relationships + +``` +Product + ├── productType → ProductType + ├── brand → Brand (optional) + ├── variants → ProductVariant[] + │ └── prices → Price[] + ├── collections ↔ Collection[] + ├── productOptions ↔ ProductOption[] + │ └── values → ProductOptionValue[] + ├── associations → ProductAssociation[] + ├── urls → Url[] + ├── tags ↔ Tag[] + └── media (Spatie MediaLibrary) +``` + +### Cart relationships + +``` +Cart + ├── lines → CartLine[] + │ └── purchasable → ProductVariant (polymorphic) + ├── shippingAddress → CartAddress + ├── billingAddress → CartAddress + ├── currency → Currency + ├── user → User + ├── customer → Customer + └── orders → Order[] +``` + +### Order relationships + +``` +Order + ├── lines → OrderLine[] + │ ├── productLines (excludes shipping type) + │ └── shippingLines (type = 'shipping') + ├── shippingAddress → OrderAddress + ├── billingAddress → OrderAddress + ├── transactions → Transaction[] + │ ├── captures (type = 'capture') + │ ├── intents (type = 'intent') + │ └── refunds (type = 'refund') + ├── customer → Customer + ├── cart → Cart + └── currency → Currency (matched on currency_code) +``` + +### Customer relationships + +``` +Customer + ├── users ↔ User[] (pivot: customer_user) + ├── customerGroups ↔ CustomerGroup[] + ├── addresses → Address[] + └── orders → Order[] +``` + +--- + +## ModelManifest — Replacing Models + +`ModelManifest` is an interface-to-implementation registry. Registering a replacement does three things: binds the contract in the container, registers route model binding, and updates the morph map. + +**Only models with contracts can be replaced.** Contracts live in `vendor/lunarphp/core/src/Models/Contracts/`. `Lunar\Admin\Models\Staff` has no contract and cannot be replaced this way. + +### Replace a single model + +```php +// In AppServiceProvider::boot() +\Lunar\Facades\ModelManifest::replace( + \Lunar\Models\Contracts\Customer::class, + \App\Models\Customer::class, +); +``` + +Your `App\Models\Customer` should extend `Lunar\Models\Customer`. + +### Replace models from a directory + +Scans the directory and replaces every model that extends a Lunar base model: + +```php +\Lunar\Facades\ModelManifest::addDirectory(__DIR__.'/../Models'); +``` + +### Dynamic relationships (preferred for simple extensions) + +For adding relationships without replacing models: + +```php +use Lunar\Models\Order; + +Order::resolveRelationUsing('ticket', function ($order) { + return $order->belongsTo(Ticket::class, 'ticket_id'); +}); +``` + +Register in a service provider `boot()`. + +### What is replaceable + +Check `vendor/lunarphp/core/src/Models/Contracts/` for available contracts: `Customer`, `Order`, `Product`, `ProductVariant`, `ProductType`, `Cart`, `CartLine`, `Channel`, `Currency`, `Language`, `Collection`, `CollectionGroup`, `Price`, `Discount`, `Tag`, `Brand`, `TaxClass`, `TaxZone`, `TaxRate`, and others. + +--- + +## Filament Panel Integration + +### Registering the panel + +```php +use Lunar\Admin\Support\Facades\LunarPanel; + +class AppServiceProvider extends ServiceProvider +{ + public function register(): void + { + LunarPanel::panel(function ($panel) { + return $panel + ->path('admin') // default: 'lunar' + ->pages([SalesReport::class]) + ->resources([BannerResource::class]) + ->plugin(new ShippingPlugin()) + ->navigationGroups(['Catalog', 'Sales', 'Settings']); + }) + ->extensions([ + CustomerResource::class => CustomerResourceExtension::class, + ]) + ->disableTwoFactorAuth() + ->register(); + } +} +``` + +`LunarPanel::register()` must be called in `register()`, not `boot()`. + +### Registering extensions (resources and pages) + +Extensions are keyed by the Lunar resource/page class: + +```php +LunarPanel::extensions([ + \Lunar\Admin\Filament\Resources\ProductResource::class => MyProductExtension::class, + \Lunar\Admin\Filament\Resources\CustomerResource::class => MyCustomerExtension::class, + \Lunar\Admin\Filament\Resources\OrderResource\Pages\ManageOrder::class => MyOrderExtension::class, +]); +``` + +### All extendable resources + +```php +use Lunar\Admin\Filament\Resources\{ + ActivityResource, AttributeGroupResource, BrandResource, ChannelResource, + CollectionGroupResource, CollectionResource, CurrencyResource, + CustomerGroupResource, CustomerResource, DiscountResource, LanguageResource, + OrderResource, ProductOptionResource, ProductResource, ProductTypeResource, + ProductVariantResource, StaffResource, TagResource, TaxClassResource, + TaxRateResource, TaxZoneResource +}; +``` + +### Staff model wiring + +`Lunar\Admin\Models\Staff` is the Filament auth model. It does **not** go through ModelManifest. To extend it: + +- Override it by extending the class and pointing Filament's auth config at your subclass. +- Listen on `Lunar\Admin\Models\Staff::created` (not your subclass) for events fired by Lunar internals. +- Use `LunarStaff::addActivitylogExcept([...])` on the base class to exclude fields from the activity log — not `getDefaultLogExcept()` on a subclass. + +--- + +## Extending Resources and Pages + +### ResourceExtension + +Extend a Lunar resource's form, table, relations, pages, and sub-navigation: + +```php +use Filament\Forms\Form; +use Filament\Tables\Table; +use Lunar\Admin\Support\Extending\ResourceExtension; + +class MyProductResourceExtension extends ResourceExtension +{ + public function extendForm(Form $form): Form + { + return $form->schema([ + ...$form->getComponents(withHidden: true), // always spread existing + TextInput::make('custom_field'), + ]); + } + + public function extendTable(Table $table): Table + { + return $table->columns([ + ...$table->getColumns(), + TextColumn::make('custom_field'), + ]); + } + + public function getRelations(array $managers): array + { + return [...$managers, MyRelationManager::class]; + } + + public function extendPages(array $pages): array + { + return [...$pages, 'my-page' => MyPage::route('/{record}/my-page')]; + } + + public function extendSubNavigation(array $nav): array + { + return [...$nav, MyPage::class]; + } + + public function headerActions(array $actions): array + { + return [...$actions, Actions\Action::make('custom')]; + } +} +``` + +### RelationManagerExtension + +```php +use Lunar\Admin\Support\Extending\RelationManagerExtension; + +class MyExtension extends RelationManagerExtension +{ + public function extendForm(Form $form): Form { /* ... */ } + public function extendTable(Table $table): Table { /* ... */ } + public function headerActions(array $actions): array { /* ... */ } +} +``` + +### Page extensions + +| Class | Use case | +|---|---| +| `CreatePageExtension` | `heading()`, `beforeCreate()`, `afterCreation()`, `formActions()`, `headerActions()`, `headerWidgets()`, `footerWidgets()` | +| `EditPageExtension` | `heading()`, `beforeFill()`, `beforeSave()`, `beforeUpdate()`, `afterUpdate()` | +| `ListPageExtension` | `heading()`, `getTabs()`, `headerActions()`, `headerWidgets()`, `footerWidgets()` | +| `ViewPageExtension` | `heading()`, `headerActions()`, `headerWidgets()`, `footerWidgets()` | +| `RelationPageExtension` | `heading()`, `headerActions()`, `extendTable()` | + +### Order management extension + +```php +LunarPanel::extensions([ + \Lunar\Admin\Filament\Resources\OrderResource\Pages\ManageOrder::class => MyOrderExtension::class, +]); +``` + +Overridable methods include `extendInfolistSchema()`, `extendOrderSummaryInfolist()`, `extendOrderTotalsInfolist()`, `extendTransactionsInfolist()`, and many targeted `extend*Entry()` methods. + +### Extending in addons (defensive pattern) + +When modifying forms in an addon, always spread existing components to avoid conflicts with other extensions: + +```php +public function extendForm(Form $form): Form +{ + $form->schema([ + ...$form->getComponents(true), + TextInput::make('my_field'), + ]); + return $form; +} +``` + +--- + +## Activity Logging + +Lunar uses [Spatie laravel-activitylog](https://spatie.be/docs/laravel-activitylog) with the trait `Lunar\Base\Traits\LogsActivity`. + +**Models logged by default**: AttributeGroup, Brand, Cart, CartAddress, CartLine, Channel, CollectionGroup, Currency, Customer, CustomerGroup, Discount, Order, OrderAddress, OrderLine, Product, ProductOption, ProductType, ProductVariant, Tag, TaxClass, TaxRate, TaxZone, Transaction. + +All entries use the `lunar` log name. Only dirty attributes are recorded; `updated_at` is excluded. + +### Adding logging to custom models + +```php +use Lunar\Base\Traits\LogsActivity; + +class MyModel extends Model +{ + use LogsActivity; + + public static function getDefaultLogExcept(): array + { + return ['internal_notes']; + } +} +``` + +### Excluding fields on Lunar's own Staff model + +Because Filament instantiates `Lunar\Admin\Models\Staff` directly (not a subclass), use this pattern in a service provider boot: + +```php +use Lunar\Admin\Models\Staff as LunarStaff; + +LunarStaff::addActivitylogExcept(['otp_code', 'otp_expires_at', 'password']); +``` + +--- + +## Pricing + +Prices are stored as integers in the smallest currency unit (e.g. cents). The `Price` model is polymorphic — any model implementing `HasPrices` can have prices. + +### Price model fields + +| Field | Description | +|---|---| +| `price` | Integer; smallest currency unit | +| `compare_price` | Optional; used for showing crossed-out "was" prices | +| `min_quantity` | Minimum quantity for this tier (default 1) | +| `currency_id` | Currency FK | +| `customer_group_id` | Nullable; for group-specific pricing | +| `priceable_type/id` | Polymorphic parent (e.g. ProductVariant) | + +### Pricing facade + +```php +use Lunar\Facades\Pricing; + +$response = Pricing::for($variant) + ->currency($currency) + ->customerGroups($customerGroups) + ->qty(5) + ->get(); + +$response->matched; // Lunar\Models\Price — best match +$response->base; // base price (qty=1, no group) +$response->priceBreaks; // Collection of tier prices +$response->customerGroupPrices; // Collection of group prices +``` + +### Price data type + +Every price attribute returns `Lunar\DataTypes\Price`: + +```php +$price->value; // raw integer +$price->decimal(); // float +$price->unitDecimal(); // float, divided by unit_quantity +$price->formatted(); // "£10.00" (uses PHP NumberFormatter) +$price->unitFormatted(); // formatted per unit +``` + +### Tax helpers on Price model + +Config `lunar.pricing.stored_inclusive_of_tax` controls whether stored prices include tax. + +```php +$priceModel->priceExTax(); // Lunar\DataTypes\Price +$priceModel->priceIncTax(); // Lunar\DataTypes\Price +$priceModel->comparePriceIncTax(); // Lunar\DataTypes\Price + +// Override tax zone (useful before a cart exists) +$priceModel->priceIncTax($taxZone); +``` + +### Custom price formatter + +Set `config/lunar/pricing.php` `formatter` to your class implementing `PriceFormatterInterface`. + +--- + +## Tax + +### Structure + +``` +TaxZone (geographic region) + └── TaxRate[] (e.g. "VAT", "State Tax") + └── TaxRateAmount[] (percentage per TaxClass) + +ProductVariant → TaxClass +``` + +### TaxZone fields + +| Field | Values | Notes | +|---|---|---| +| `zone_type` | `country`, `states`, `postcodes` | Determines scope | +| `price_display` | `tax_inclusive`, `tax_exclusive` | Controls how prices display | +| `default` | boolean | Fallback zone | + +### TaxRate and TaxRateAmount + +```php +$taxZone->taxRates()->create(['name' => 'VAT', 'priority' => 1]); +$taxRate->taxRateAmounts()->create([ + 'tax_class_id' => $defaultTaxClass->id, + 'percentage' => 20.000, +]); +``` + +### Custom tax driver + +Implement `Lunar\Base\TaxDriver` and register: + +```php +\Lunar\Facades\Taxes::extend('taxjar', fn ($app) => $app->make(TaxJar::class)); +``` + +Then set `config/lunar/taxes.php` `driver` to `'taxjar'`. + +--- + +## Channels and Currencies + +### Channels + +Channels represent sales outlets (webstore, wholesale portal, etc.). Products and collections are published per channel with optional start/end dates. + +```php +$product->scheduleChannel($channel); +$product->scheduleChannel($channel, now()->addDays(14), now()->addDays(28)); +$products = Product::channel($channel)->get(); +``` + +### Currencies + +```php +$currency->code; // e.g. 'GBP' +$currency->exchange_rate; // relative to default currency +$currency->decimal_places; // e.g. 2 +$currency->default; // boolean +``` + +Exchange rates do not auto-convert prices unless `sync_prices` is enabled on the non-default currency. + +### Storefront session + +Manages the active channel, currency, customer, and customer groups for the session: + +```php +use Lunar\Facades\StorefrontSession; + +$channel = StorefrontSession::getChannel(); +$currency = StorefrontSession::getCurrency(); +$customer = StorefrontSession::getCustomer(); +$groups = StorefrontSession::getCustomerGroups(); + +StorefrontSession::setChannel($channel); +StorefrontSession::setCurrency($currency); +StorefrontSession::setCustomer($customer); // validates customer belongs to user +``` + +Customer resolution order: session → `$user->latestCustomer()`. + +--- + +## Cart and Checkout + +### CartSession facade + +```php +use Lunar\Facades\CartSession; + +$cart = CartSession::current(); // calculates totals; returns null if no cart +$cart->recalculate(); // force recalculation + +CartSession::createOrder(); // creates order, removes cart from session +CartSession::createOrder(forget: false); // keeps cart in session +CartSession::forget(); // clear session (soft deletes cart by default) +CartSession::forget(delete: false); // clear session, keep cart in DB +``` + +### Adding items + +```php +$cart->add($variant, quantity: 2, meta: ['gift_wrap' => true]); + +$cart->addLines([ + ['purchasable' => $variant, 'quantity' => 2], +]); +``` + +### Updating and removing + +```php +$cart->updateLine($cartLineId, quantity: 3, meta: [...]); +$cart->remove($cartLineId); +$cart->clear(); +``` + +### Addresses + +```php +$cart->setShippingAddress([ + 'country_id' => $id, 'first_name' => '...', 'line_one' => '...', 'city' => '...', 'postcode' => '...', +]); +$cart->setBillingAddress([/* same fields */]); +``` + +Accepts an array or any model implementing `Lunar\Base\Addressable` (e.g. `Lunar\Models\Address`). + +### Cart total properties (after calculate/recalculate) + +| Property | Description | +|---|---| +| `total` | Grand total including tax | +| `subTotal` | Sum of lines before tax and discounts | +| `subTotalDiscounted` | Subtotal after discounts | +| `taxTotal` | Total tax | +| `discountTotal` | Total discounts | +| `shippingSubTotal` | Shipping before tax | +| `shippingTaxTotal` | Tax on shipping | +| `shippingTotal` | Shipping including tax | +| `taxBreakdown` | TaxBreakdown object; iterate `.amounts` | +| `discountBreakdown` | Collection of discount breakdowns | +| `shippingBreakdown` | Collection of shipping line items | + +### CartLine properties (after calculate) + +`unitPrice`, `unitPriceInclTax`, `subTotal`, `subTotalDiscounted`, `discountTotal`, `taxAmount`, `total` + +### Cart exceptions + +| Exception | Cause | +|---|---| +| `Lunar\Exceptions\Carts\CartException` | Validation failure; access errors via `$e->errors()` | +| `InvalidCartLineQuantityException` | Quantity <= 0 | +| `NonPurchasableItemException` | Model does not implement `Purchasable` | +| `CartLineIdMismatchException` | Cart line does not belong to cart | +| `DisallowMultipleCartOrdersException` | Cart already has a completed order | +| `FingerprintMismatchException` | Cart contents changed since fingerprint was generated | + +### Cart fingerprint + +```php +$fingerprint = $cart->fingerprint(); +// ... pass to frontend hidden input ... +$cart->checkFingerprint($request->fingerprint); // throws FingerprintMismatchException +``` + +### Cart pipelines + +Configurable in `config/lunar/cart.php`: + +``` +CalculateLines → ApplyShipping → ApplyDiscounts → CalculateTax → Calculate +``` + +Cart line pipeline: `GetUnitPrice` + +Custom pipeline class: + +```php +class MyPipeline +{ + public function handle(Cart $cart, Closure $next): mixed + { + // modify cart... + return $next($cart); + } +} +``` + +### Cart auth policy (login/logout behavior) + +```php +// config/lunar/cart.php +'auth_policy' => 'merge', // or 'override' +``` + +`merge` — guest cart items combine with user's existing cart on login. +`override` — guest cart replaces user's cart. + +### Shipping options + +```php +use Lunar\Facades\ShippingManifest; + +$options = ShippingManifest::getOptions($cart); // Collection of ShippingOption +$option = ShippingManifest::getOption($cart, $identifier); + +$cart->setShippingOption($option); // triggers recalculate +``` + +`ShippingOption` properties: `name`, `description`, `identifier`, `price`, `collect` (bool). + +--- + +## Orders + +### Order creation from cart + +```php +$order = $cart->createOrder(); +// or via CartSession (also handles session cleanup): +$order = CartSession::createOrder(); +``` + +Validation requirements before `createOrder()`: + +- Billing address with `country_id`, `first_name`, `line_one`, `city`, `postcode` +- Shipping address (same fields) if cart contains shippable items +- A shipping option must be selected if cart is shippable + +### Draft vs placed + +```php +$order->isDraft(); // placed_at is null +$order->isPlaced(); // placed_at is not null +``` + +Only placed orders should be shown in the customer's order history. + +### Order fields (key ones) + +| Field | Type | Description | +|---|---|---| +| `status` | string | e.g. `payment-received`, `dispatched` | +| `reference` | string | Auto-generated; format configurable | +| `placed_at` | dateTime\|null | null = draft | +| `sub_total` | Price | Subtotal excluding tax | +| `tax_total` | Price | Total tax | +| `shipping_total` | Price | Shipping including tax | +| `discount_total` | Price | Total discounts | +| `total` | Price | Grand total | +| `currency_code` | string | ISO currency at time of order | +| `meta` | json | Custom metadata | + +### Order line types + +- `physical` — physical product +- `digital` — digital product +- `shipping` — shipping charge + +Access via `$order->productLines` (physical + digital), `$order->shippingLines`. + +### Order pipelines + +Configurable in `config/lunar/orders.php`: + +``` +FillOrderFromCart → CreateOrderLines → CreateOrderAddresses → +CreateShippingLine → CleanUpOrderLines → MapDiscountBreakdown +``` + +### Order reference format + +Default: zero-padded order ID (8 digits). Configurable in `config/lunar/orders.php`: + +```php +'reference_format' => [ + 'prefix' => null, + 'padding_direction' => STR_PAD_LEFT, + 'padding_character' => '0', + 'length' => 8, +], +``` + +### Transactions + +| Field | Description | +|---|---| +| `type` | `intent`, `capture`, or `refund` | +| `success` | boolean | +| `amount` | integer (smallest unit) | +| `driver` | e.g. `stripe` | +| `reference` | provider reference | +| `card_type` | e.g. `visa` | +| `last_four` | last 4 digits | +| `parent_transaction_id` | links capture to intent, refund to capture | + +--- + +## Customers and Addresses + +### Customer fields + +| Field | Description | +|---|---| +| `first_name`, `last_name` | Name | +| `company_name` | Optional | +| `tax_identifier` | VAT / tax ID | +| `account_ref` | External reference | +| `attribute_data` | Custom attributes | +| `meta` | Flexible JSON | + +Accessor: `$customer->full_name` → `"Mr. Tony Stark"` + +### Linking users to customers + +```php +$customer = Customer::create([...]); +$customer->users()->attach($user); + +// For B2B: multiple users per customer +$customer->users()->sync([$userA->id, $userB->id]); +``` + +### Customer groups + +Customer groups (`retail`, `wholesale`, etc.) control pricing, product visibility, and discount eligibility. Default group: `retail`. + +```php +$customer->customerGroups()->attach($group); + +// Filter products/collections by group +Product::customerGroup($group)->get(); + +// Schedule product availability for a group +$product->scheduleCustomerGroup($group, starts: now()->addDays(7)); +$product->unscheduleCustomerGroup($group); +``` + +### Saved addresses + +`Address` belongs to `Customer`. Fields: `first_name`, `last_name`, `company_name`, `tax_identifier`, `line_one` through `line_three`, `city`, `state`, `postcode`, `country_id`, `contact_email`, `contact_phone`, `delivery_instructions`, `shipping_default`, `billing_default`, `meta`. + +When `shipping_default` is set to true, Lunar automatically unsets the previous default via an observer. + +```php +$customer->addresses()->create([...]); +$customer->addresses()->where('shipping_default', true)->first(); +``` + +--- + +## Discounts + +### Discount model fields + +| Field | Description | +|---|---| +| `name` | Display name | +| `coupon` | Optional coupon code | +| `type` | Fully-qualified class name of the discount type | +| `starts_at` / `ends_at` | Active window | +| `max_uses` | Global usage limit | +| `max_uses_per_user` | Per-user limit | +| `priority` | Processing order | +| `stop` | Stop processing further discounts if applied | +| `data` | Type-specific config (JSON) | + +### Built-in discount types + +- `Lunar\DiscountTypes\AmountOff` — percentage or fixed amount off +- `Lunar\DiscountTypes\BuyXGetY` — buy X items, get Y free/discounted + +### Discount status + +`$discount->status` returns: `active`, `pending`, `expired`, or `scheduled`. + +### Applying discounts + +Discounts are applied automatically during cart calculation when `coupon_code` is set on the cart, or based on cart contents for automatic discounts. + +```php +$cart->update(['coupon_code' => '20OFF']); +$cart->recalculate(); + +Discounts::validateCoupon('20OFF'); // bool +Discounts::resetDiscounts(); // clear cached discounts for current request +``` + +### Custom discount types + +```php +use Lunar\Facades\Discounts; + +Discounts::addType(MyCustomDiscountType::class); +``` + +Implement `Lunar\DiscountTypes\AbstractDiscountType`. For admin panel support, also implement `Lunar\Admin\Base\LunarPanelDiscountInterface` and provide `lunarPanelSchema()`, `lunarPanelOnFill()`, `lunarPanelOnSave()`. + +--- + +## Products and Variants + +### ProductVariant fields (key) + +| Field | Description | +|---|---| +| `sku` | Stock keeping unit | +| `stock` | Stock quantity | +| `purchasable` | `always` or `in_stock` | +| `unit_quantity` | Units per price point (default 1) | +| `weight_value`, `weight_unit` | For shipping weight calculations | +| `tax_class_id` | Determines applicable tax rate | + +### Purchasable values + +- `always` — can always be purchased (backorder) +- `in_stock` — only when `stock > 0` + +```php +$variant->canBeFulfilledAtQuantity(3); // bool +``` + +### Attributes + +All custom product/collection/brand attributes are stored in `attribute_data` as JSON. Access via `$model->attr('name')` which resolves translations based on current locale. + +Attribute field types: `Text`, `TranslatedText`, `Number`, `Toggle`, `Dropdown`, `ListField`, `File`, `YouTube`, `Vimeo`. + +### Product associations + +```php +use Lunar\Base\Enums\ProductAssociation; + +$product->associate($target, ProductAssociation::CROSS_SELL); +$product->associate($target, ProductAssociation::UP_SELL); +$product->associate($target, ProductAssociation::ALTERNATE); +$product->associate([$a, $b], ProductAssociation::CROSS_SELL); + +$product->associations()->crossSell()->with('target')->get(); +$product->dissociate($target); +``` + +### Pricing facade for products + +```php +$pricing = Pricing::for($variant) + ->currency(StorefrontSession::getCurrency()) + ->customerGroups(StorefrontSession::getCustomerGroups()) + ->get(); + +$pricing->matched; // best price for this context +$pricing->priceBreaks; // quantity tiers +``` + +--- + +## Collections and URLs + +### Collection hierarchy + +Collections use a nested set (via `HasNestedSets`). Each collection belongs to a `CollectionGroup`. + +```php +$rootCollections = Collection::where('collection_group_id', $group->id) + ->whereIsRoot() + ->defaultOrder() + ->with(['defaultUrl', 'children' => fn ($q) => $q->defaultOrder(), 'children.defaultUrl']) + ->get(); + +// Full tree +$tree = Collection::where('collection_group_id', $group->id) + ->defaultOrder() + ->with('defaultUrl') + ->get() + ->toTree(); +``` + +### URLs (slugs) + +```php +use Lunar\Models\Url; + +// Resolve product from slug +$url = Url::where('slug', $slug) + ->where('element_type', (new Product)->getMorphClass()) + ->firstOrFail(); +$product = Product::find($url->element_id); + +// Create a URL +$product->urls()->create([ + 'slug' => 'my-product', + 'language_id' => $language->id, + 'default' => true, +]); + +// Access URLs +$product->defaultUrl; // default URL for current locale +$product->localeUrl; // URL for current app locale +$product->urls; // all URLs +``` + +Auto-generation from `attr('name')` is enabled by default via `Lunar\Generators\UrlGenerator`. Configure in `config/lunar/urls.php`. + +Add URL support to custom models with the `HasUrls` trait. + +--- + +## Payments + +### Payments facade + +```php +use Lunar\Facades\Payments; + +// Authorize +$response = Payments::driver('card') + ->cart($cart) + ->withData(['payment_intent' => $intentId]) + ->authorize(); + +$response->success; // bool +$response->orderId; // int|null +$response->message; // string|null +$response->paymentType; // string|null + +// Capture (manual capture policy) +Payments::driver('card')->order($order)->capture($transaction, $amount); + +// Refund +Payments::driver('card')->order($order)->refund($transaction, $amount, 'notes'); +``` + +### Payment types configuration + +In `config/lunar/payments.php`: + +```php +'types' => [ + 'card' => [ + 'driver' => 'stripe', + 'released' => 'payment-received', + ], + 'cash-in-hand' => [ + 'driver' => 'offline', + 'authorized' => 'payment-offline', + ], +], +``` + +### Custom payment driver + +```php +use Lunar\Facades\Payments; + +Payments::extend('custom', fn ($app) => $app->make(CustomPayment::class)); +``` + +Extend `Lunar\PaymentTypes\AbstractPayment`. Implement `authorize()`, `capture()`, `refund()`. + +Transaction types: `intent` (payment reserved, not yet captured), `capture` (payment captured), `refund`. + +### Stripe integration + +```php +use Lunar\Stripe\Facades\Stripe; + +$intent = Stripe::fetchOrCreateIntent($cart); // create/get PaymentIntent +Stripe::syncIntent($cart); // update amount after cart changes +Stripe::cancelIntent($cart, CancellationReason::ABANDONED); +$intentId = Stripe::getCartIntentId($cart); // get existing intent ID +``` + +Webhook endpoint auto-registered at `stripe/webhook` (configurable). Verify with `LUNAR_STRIPE_WEBHOOK_SECRET`. + +--- + +## Shipping + +### Adding a custom shipping modifier + +```php +namespace App\Modifiers; + +use Lunar\Base\ShippingModifier; +use Lunar\DataTypes\Price; +use Lunar\DataTypes\ShippingOption; +use Lunar\Facades\ShippingManifest; + +class CustomShippingModifier extends ShippingModifier +{ + public function handle(Cart $cart, \Closure $next) + { + ShippingManifest::addOption( + new ShippingOption( + name: 'Standard Delivery', + description: 'Delivered in 3-5 days', + identifier: 'STANDARD', + price: new Price(500, $cart->currency, 1), + taxClass: TaxClass::first(), + ) + ); + + return $next($cart); + } +} +``` + +Register in a service provider: + +```php +public function boot(\Lunar\Base\ShippingModifiers $shippingModifiers): void +{ + $shippingModifiers->add(CustomShippingModifier::class); +} +``` + +### Table Rate Shipping add-on + +Install `lunarphp/table-rate-shipping`. Register the Filament plugin: + +```php +LunarPanel::panel(fn ($panel) => $panel->plugin(new ShippingPlugin()))->register(); +``` + +Concepts: **ShippingZone** (geographic region), **ShippingMethod** (delivery type), **ShippingRate** (zone + method + prices), **ShippingExclusionList** (products blocked from a zone). + +Drivers: `ship-by` (cart total or weight tiers), `flat-rate`, `free-shipping` (minimum spend), `collection` (in-store pickup). + +--- + +## Search Indexing + +Lunar uses Laravel Scout for indexing. Models with the `Searchable` trait use indexers configured in `config/lunar/search.php`. + +### ProductIndexer fields + +`id`, searchable attributes, `status`, `product_type`, `brand`, variant `skus`, `created_at`. + +### Custom indexer + +```php +// config/lunar/search.php +'indexers' => [ + Lunar\Models\Product::class => App\Search\CustomProductIndexer::class, +], +``` + +Custom indexer extends `Lunar\Search\ScoutIndexer`: + +```php +class CustomProductIndexer extends ScoutIndexer +{ + public function toSearchableArray(Model $model): array + { + return array_merge([], $this->mapSearchableAttributes($model)); + } + + public function getSortableFields(): array { return ['created_at']; } + public function getFilterableFields(): array { return ['status']; } +} +``` + +`mapSearchableAttributes()` automatically includes all attributes flagged as searchable in the admin panel. + +--- + +## Access Control + +Lunar uses `spatie/laravel-permission` scoped to the `staff` guard. + +Built-in roles: `admin`, `staff`. + +### Adding permissions + +Create via migration (not through the UI — authorization logic must accompany every permission): + +```php +// In a migration +\Spatie\Permission\Models\Permission::create(['name' => 'manage-contracts', 'guard_name' => 'staff']); +``` + +### Two-factor auth + +```php +LunarPanel::forceTwoFactorAuth()->register(); // enforce 2FA for all staff +LunarPanel::disableTwoFactorAuth()->register(); // disable 2FA entirely (this project uses OTP instead) +``` + +### Authorization checks + +```php +// Route middleware +Route::get('/custom', Controller::class)->middleware('can:manage-contracts'); + +// In code +Auth::user()->can('manage-contracts'); +``` + +--- + +## Useful Facades Summary + +| Facade | Purpose | +|---|---| +| `Lunar\Facades\CartSession` | Manage the active cart session | +| `Lunar\Facades\Payments` | Route payment calls to drivers | +| `Lunar\Facades\Pricing` | Resolve the best price for a purchasable | +| `Lunar\Facades\ShippingManifest` | Get/set shipping options on a cart | +| `Lunar\Facades\Discounts` | Validate coupons, register custom types | +| `Lunar\Facades\Taxes` | Register custom tax drivers | +| `Lunar\Facades\ModelManifest` | Replace Lunar models with custom implementations | +| `Lunar\Facades\StorefrontSession` | Manage channel, currency, customer for session | +| `Lunar\Admin\Support\Facades\LunarPanel` | Configure and extend the Filament panel | + +--- + +## Artisan Commands + +```bash +php artisan lunar:install # first-time setup +php artisan vendor:publish --tag=lunar # publish all config +php artisan vendor:publish --tag=lunar.migrations # publish migrations +php artisan scout:import "Lunar\Models\Product" # index products +php artisan scout:flush "Lunar\Models\Order" # flush order index +``` diff --git a/docs/otp-auth.md b/docs/otp-auth.md new file mode 100644 index 0000000..5dd8832 --- /dev/null +++ b/docs/otp-auth.md @@ -0,0 +1,67 @@ +# OTP Authentication + +Staff authentication uses a passwordless OTP flow. No password is stored or required. + +--- + +## Flow + +1. Staff enters their email on the login page +2. A 6-digit code is generated, stored on the `staff` record, and emailed +3. Staff enters the code — if valid and not expired, they are logged in + +--- + +## Implementation + +### OtpService + +`Modules\Core\Services\OtpService` handles generation and validation. + +```php +// Generate and email a code +$otpService->generateAndSend(string $email): bool + +// Validate a submitted code — returns the Staff model on success, null on failure +$otpService->validate(string $email, string $code): ?Staff +``` + +Codes expire after **10 minutes**. After a successful validation the code is cleared from the record. + +### Database + +Two columns on the `lunar_staff` table (added by `2026_05_06_000001_add_otp_to_lunar_staff_table`): + +| Column | Type | Purpose | +|---|---|---| +| `otp_code` | string, nullable | The generated code | +| `otp_expires_at` | timestamp, nullable | Expiry time | + +### Login Page + +`Modules\Core\Filament\Pages\Login` is a Filament `SimplePage` registered as the panel login via `AppServiceProvider`: + +```php +LunarPanel::panel(fn (Panel $panel) => $panel->login(Login::class)); +``` + +It has two steps rendered in `core::filament.pages.login`: + +- **Step 1** — email form, submits to `requestOtp()` +- **Step 2** — code form, submits to `authenticate()` + +After a valid code `canAccessPanel()` is checked before the session is established. + +### Email + +`Modules\Core\Mail\OtpMail` sends the code using the `core::mail.otp` view. + +--- + +## Disabling 2FA + +Lunar's built-in 2FA plugin is disabled in `AppServiceProvider` since the OTP login replaces it: + +```php +LunarPanel::disableTwoFactorAuth(); +``` diff --git a/resources/logos/boboko-logo-white.svg b/resources/logos/boboko-logo-white.svg new file mode 100644 index 0000000..f778f35 --- /dev/null +++ b/resources/logos/boboko-logo-white.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/logos/boboko-logo.svg b/resources/logos/boboko-logo.svg new file mode 100644 index 0000000..4239989 --- /dev/null +++ b/resources/logos/boboko-logo.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/views/auth/filament/pages/login.blade.php b/resources/views/auth/filament/pages/login.blade.php new file mode 100644 index 0000000..0f13e48 --- /dev/null +++ b/resources/views/auth/filament/pages/login.blade.php @@ -0,0 +1,53 @@ + + + @if (! $otpSent) +
+
+ + + + + @error('email') +

{{ $message }}

+ @enderror + + + Send login code + +
+
+ @else +
+
+

+ A login code was sent to {{ $email }}. +

+ + + + + + @error('otp') +

{{ $message }}

+ @enderror + + + Sign in + +
+
+ @endif + +
diff --git a/resources/views/auth/mail/customer-otp.blade.php b/resources/views/auth/mail/customer-otp.blade.php new file mode 100644 index 0000000..9b0d2a1 --- /dev/null +++ b/resources/views/auth/mail/customer-otp.blade.php @@ -0,0 +1,17 @@ +@extends('emails.layout') + +@section('content') +

Γειά σου {{ $name }},

+ +

Ο κωδικός σύνδεσής σου είναι:

+ + + + + +
+ {{ $code }} +
+ +

Αν δεν ζήτησες εσύ αυτόν τον κωδικό, μπορείς να αγνοήσεις αυτό το email.

+@endsection diff --git a/resources/views/auth/mail/invite.blade.php b/resources/views/auth/mail/invite.blade.php new file mode 100644 index 0000000..5789576 --- /dev/null +++ b/resources/views/auth/mail/invite.blade.php @@ -0,0 +1,5 @@ +

Hi {{ $name }},

+ +

You have been invited to access the admin panel. Visit the link below to log in — you will be asked for your email address and a one-time code will be sent to you.

+ +

Go to admin panel

diff --git a/resources/views/auth/mail/otp.blade.php b/resources/views/auth/mail/otp.blade.php new file mode 100644 index 0000000..a10ac3e --- /dev/null +++ b/resources/views/auth/mail/otp.blade.php @@ -0,0 +1,5 @@ +

Hi {{ $name }},

+ +

Your login code is:

+ +

{{ $code }}

\ No newline at end of file diff --git a/src/Auth/Extensions/StaffResourceExtension.php b/src/Auth/Extensions/StaffResourceExtension.php new file mode 100644 index 0000000..02d04db --- /dev/null +++ b/src/Auth/Extensions/StaffResourceExtension.php @@ -0,0 +1,19 @@ +getComponents()) + ->reject(fn ($component) => method_exists($component, 'getName') && $component->getName() == 'password') + ->values() + ->all(); + + return $form->schema($schema); + } +} diff --git a/src/Auth/Filament/Pages/Login.php b/src/Auth/Filament/Pages/Login.php new file mode 100644 index 0000000..d83d847 --- /dev/null +++ b/src/Auth/Filament/Pages/Login.php @@ -0,0 +1,97 @@ +otpService = $otpService; + } + + public function mount(): void + { + if (Filament::auth()->check()) { + redirect()->intended(Filament::getUrl()); + } + } + + public function requestOtp(): void + { + $this->validate(['email' => 'required|email']); + + try { + $this->rateLimit(5); + } catch (TooManyRequestsException) { + throw ValidationException::withMessages([ + 'email' => 'Too many attempts. Please wait before trying again.', + ]); + } + + $this->otpService->generateAndSend($this->email); + + $this->otpSent = true; + } + + public function authenticate(): void + { + $this->validate(['otp' => 'required']); + + try { + $this->rateLimit(5); + } catch (TooManyRequestsException) { + throw ValidationException::withMessages([ + 'otp' => 'Too many attempts. Please wait before trying again.', + ]); + } + + $staff = $this->otpService->validate($this->email, $this->otp); + + if (!$staff) { + throw ValidationException::withMessages([ + 'otp' => 'Invalid or expired code.', + ]); + } + + if ($staff instanceof FilamentUser && !$staff->canAccessPanel(Filament::getCurrentPanel())) { + throw ValidationException::withMessages([ + 'email' => 'You do not have access to this panel.', + ]); + } + + Filament::auth()->login($staff); + + session()->regenerate(); + + redirect()->intended(Filament::getUrl()); + } + + public function getTitle(): string | Htmlable + { + return 'Sign in'; + } + + public function getHeading(): string | Htmlable + { + return 'Sign in to your account'; + } +} diff --git a/src/Auth/Mail/CustomerOtpMail.php b/src/Auth/Mail/CustomerOtpMail.php new file mode 100644 index 0000000..04aa4a3 --- /dev/null +++ b/src/Auth/Mail/CustomerOtpMail.php @@ -0,0 +1,25 @@ + 'bool', + 'email_verified_at' => 'datetime', + 'password' => 'hashed', + 'otp_expires_at' => 'datetime', + ]; +} diff --git a/src/Auth/Services/CustomerOtpService.php b/src/Auth/Services/CustomerOtpService.php new file mode 100644 index 0000000..64e2c43 --- /dev/null +++ b/src/Auth/Services/CustomerOtpService.php @@ -0,0 +1,61 @@ +findByEmail($email); + + if (! $customer) { + return false; + } + + $code = str_pad((string) random_int(0, 999999), self::CODE_LENGTH, '0', STR_PAD_LEFT); + + $customer->otp_code = $code; + $customer->otp_expires_at = now()->addMinutes(self::EXPIRY_MINUTES); + $customer->save(); + + Mail::to($email)->send(new CustomerOtpMail($customer->full_name, $code)); + + return true; + } + + public function validate(string $email, string $code): ?Customer + { + $customer = $this->findByEmail($email); + + if (! $customer) { + return null; + } + + if (! $customer->otp_expires_at || $customer->otp_code != $code || now()->isAfter($customer->otp_expires_at)) { + return null; + } + + $customer->otp_code = null; + $customer->otp_expires_at = null; + $customer->save(); + + return $customer; + } + + private function findByEmail(string $email): ?Customer + { + $model = ModelManifest::get(Customer::class); + + return $model::query() + ->whereHas('emails', fn ($query) => $query->where('email', $email)) + ->first(); + } +} diff --git a/src/Auth/Services/OtpService.php b/src/Auth/Services/OtpService.php new file mode 100644 index 0000000..426b340 --- /dev/null +++ b/src/Auth/Services/OtpService.php @@ -0,0 +1,51 @@ +first(); + + if (! $staff) { + return false; + } + + $code = str_pad((string) random_int(0, 999999), self::CODE_LENGTH, '0', STR_PAD_LEFT); + + $staff->otp_code = $code; + $staff->otp_expires_at = now()->addMinutes(self::EXPIRY_MINUTES); + $staff->save(); + + Mail::to($staff->email)->send(new OtpMail($staff->first_name, $code)); + + return true; + } + + public function validate(string $email, string $code): ?Staff + { + $staff = Staff::where('email', $email)->first(); + + if (! $staff) { + return null; + } + + if (! $staff->otp_expires_at || $staff->otp_code != $code || now()->isAfter($staff->otp_expires_at)) { + return null; + } + + $staff->otp_code = null; + $staff->otp_expires_at = null; + $staff->save(); + + return $staff; + } +} diff --git a/src/Command/AnonymizeCommand.php b/src/Command/AnonymizeCommand.php new file mode 100644 index 0000000..71c8d17 --- /dev/null +++ b/src/Command/AnonymizeCommand.php @@ -0,0 +1,65 @@ +environment("local")) { + $this->error( + "This command can only be run in the local environment.", + ); + return; + } + + if ( + !$this->confirm( + "This will permanently overwrite personal data. Continue?", + ) + ) { + return; + } + + DB::table("users") + ->get() + ->each(function ($user) { + DB::table("users") + ->where("id", $user->id) + ->update([ + "name" => "User {$user->id}", + "email" => "user_{$user->id}@example.com", + "password" => Hash::make("password"), + "remember_token" => null, + ]); + }); + + $this->info("Users anonymized."); + + DB::table("lunar_customers") + ->get() + ->each(function ($customer) { + DB::table("lunar_customers") + ->where("id", $customer->id) + ->update([ + "title" => null, + "first_name" => "Customer", + "last_name" => (string) $customer->id, + "company_name" => null, + "tax_identifier" => null, + "account_ref" => null, + "meta" => null, + ]); + }); + + $this->info("Customers anonymized."); + } +} diff --git a/src/Command/CreateAdminCommand.php b/src/Command/CreateAdminCommand.php new file mode 100644 index 0000000..a728930 --- /dev/null +++ b/src/Command/CreateAdminCommand.php @@ -0,0 +1,42 @@ + $this->options['firstname'] ?? text( + label: 'First Name', + required: true, + ), + + 'last_name' => $this->options['lastname'] ?? text( + label: 'Last Name', + required: true, + ), + + 'email' => $this->options['email'] ?? text( + label: 'Email address', + required: true, + validate: fn (string $email): ?string => match (true) { + ! filter_var($email, FILTER_VALIDATE_EMAIL) => 'The email address must be valid.', + \Lunar\Admin\Models\Staff::where('email', $email)->exists() => 'A user with this email address already exists', + default => null, + }, + ), + + 'admin' => true, + ]; + } +} diff --git a/src/Command/ExportCleanupCommand.php b/src/Command/ExportCleanupCommand.php new file mode 100644 index 0000000..c27a49d --- /dev/null +++ b/src/Command/ExportCleanupCommand.php @@ -0,0 +1,41 @@ +path("exports"); + $files = glob($exportDir . "/export_*.zip"); + + if (empty($files)) { + $this->info("No export files found."); + return; + } + + rsort($files); + + $keep = max(0, (int) $this->option("keep")); + $toDelete = array_slice($files, $keep); + + if (empty($toDelete)) { + $this->info("Nothing to delete."); + return; + } + + foreach ($toDelete as $file) { + unlink($file); + $this->line("Deleted: " . basename($file)); + } + + $this->info("Deleted " . count($toDelete) . " export(s)."); + } +} diff --git a/src/Command/ExportCommand.php b/src/Command/ExportCommand.php new file mode 100644 index 0000000..5a01855 --- /dev/null +++ b/src/Command/ExportCommand.php @@ -0,0 +1,112 @@ +path("exports"); + if (!is_dir($exportDir)) { + mkdir($exportDir, 0755, true); + } + + $stamp = now()->format("Y_m_d_His"); + $sqlFile = $exportDir . "/dump_{$stamp}.sql"; + $zipFile = $exportDir . "/export_{$stamp}.zip"; + $publicDisk = Storage::disk("public"); + $filesDir = $publicDisk->path(""); + + $result = $this->dumpDatabase($db, $sqlFile)->flatMap( + fn($sql) => $this->buildZip($sql, $filesDir, $zipFile), + ); + + if (file_exists($sqlFile)) { + unlink($sqlFile); + } + + if ($result->error()->isDefined()) { + $this->error($result->error()->get()); + return; + } + + $this->info("Exported to {$zipFile}"); + } + + /** @return Result */ + private function dumpDatabase(array $db, string $sqlFile): Result + { + $command = sprintf( + "PGPASSWORD=%s pg_dump -h %s -p %s -U %s -d %s --no-owner --no-acl > %s", + $db["password"], + $db["host"], + $db["port"], + $db["username"], + $db["database"], + $sqlFile, + ); + + exec($command, result_code: $code); + + if ($code !== 0) { + return Error::create("pg_dump failed."); + } + + $this->info("Database dumped."); + + return Success::create($sqlFile); + } + + /** @return Result */ + private function buildZip( + string $sqlFile, + string $filesDir, + string $zipFile, + ): Result { + $zip = new ZipArchive(); + if ( + $zip->open($zipFile, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== + true + ) { + return Error::create("Could not create zip archive."); + } + + $zip->addFile($sqlFile, basename($sqlFile)); + + if (is_dir($filesDir)) { + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator( + $filesDir, + \FilesystemIterator::SKIP_DOTS, + ), + ); + foreach ($iterator as $file) { + $relativePath = + "files/" . + ltrim( + str_replace($filesDir, "", $file->getRealPath()), + DIRECTORY_SEPARATOR, + ); + $zip->addFile($file->getRealPath(), $relativePath); + } + } + + $zip->close(); + + return Success::create($zipFile); + } +} diff --git a/src/Command/ImportCommand.php b/src/Command/ImportCommand.php new file mode 100644 index 0000000..4f5519a --- /dev/null +++ b/src/Command/ImportCommand.php @@ -0,0 +1,167 @@ +environment("local")) { + $this->error( + "This command can only be run in the local environment.", + ); + return; + } + + $localDisk = Storage::disk("local"); + $exports = array_values(array_filter( + $localDisk->files("exports"), + fn($f) => fnmatch("exports/export_*.zip", $f), + )); + + if (empty($exports)) { + $this->error("No export files found."); + return; + } + + rsort($exports); + $choices = array_map(fn($f) => basename($f), $exports); + $chosen = $this->choice("Select an export file to import:", $choices, 0); + $zipFile = $localDisk->path("exports/" . $chosen); + + $db = config("database.connections.pgsql"); + $publicDisk = Storage::disk("public"); + $tempPath = "boboko_import_" . uniqid(); + + $result = $this->extractZip($zipFile, $localDisk, $tempPath) + ->flatMap(fn($path) => $this->restoreDatabase($db, $localDisk, $path)) + ->flatMap(fn($path) => $this->restoreFiles($localDisk, $path, $publicDisk)); + + $localDisk->deleteDirectory($tempPath); + + if ($result->error()->isDefined()) { + $this->error($result->error()->get()); + return; + } + + $this->info("Import complete."); + $this->call("boboko:anonymize"); + } + + /** @return Result */ + private function extractZip( + string $zipFile, + Filesystem $localDisk, + string $tempPath, + ): Result { + $zip = new ZipArchive(); + if ($zip->open($zipFile) !== true) { + return Error::create("Could not open zip archive."); + } + + $localDisk->makeDirectory($tempPath); + $zip->extractTo($localDisk->path($tempPath)); + $zip->close(); + + $this->info("Archive extracted."); + + return Success::create($tempPath); + } + + /** @return Result */ + private function restoreDatabase( + array $db, + Filesystem $localDisk, + string $tempPath, + ): Result { + $sqlFiles = array_values(array_filter( + $localDisk->files($tempPath), + fn($f) => fnmatch("dump_*.sql", basename($f)), + )); + + if (empty($sqlFiles)) { + return Error::create("No SQL dump found in archive."); + } + + $dropCommand = sprintf( + "PGPASSWORD=%s psql -h %s -p %s -U %s -d %s -c 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;'", + $db["password"], + $db["host"], + $db["port"], + $db["username"], + $db["database"], + ); + + exec($dropCommand, result_code: $dropCode); + + if ($dropCode !== 0) { + return Error::create("Failed to reset database schema."); + } + + $command = sprintf( + "PGPASSWORD=%s psql -h %s -p %s -U %s -d %s < %s", + $db["password"], + $db["host"], + $db["port"], + $db["username"], + $db["database"], + $localDisk->path($sqlFiles[0]), + ); + + exec($command, result_code: $code); + + if ($code !== 0) { + return Error::create("psql restore failed."); + } + + $this->info("Database restored."); + + return Success::create($tempPath); + } + + /** @return Result */ + private function restoreFiles( + Filesystem $localDisk, + string $tempPath, + Filesystem $publicDisk, + ): Result { + $filesSubPath = $tempPath . "/files"; + + if (!$localDisk->exists($filesSubPath)) { + $this->info("No files to restore."); + return Success::create($tempPath); + } + + foreach ($publicDisk->allFiles() as $file) { + $publicDisk->delete($file); + } + foreach ($publicDisk->directories() as $dir) { + $publicDisk->deleteDirectory($dir); + } + + $tempDisk = Storage::build([ + "driver" => "local", + "root" => $localDisk->path($filesSubPath), + ]); + + foreach ($tempDisk->allFiles() as $relativePath) { + $publicDisk->put($relativePath, $tempDisk->readStream($relativePath)); + } + + $this->info("Files restored."); + + return Success::create($tempPath); + } +} diff --git a/src/CorePlugin.php b/src/CorePlugin.php new file mode 100644 index 0000000..ed8f344 --- /dev/null +++ b/src/CorePlugin.php @@ -0,0 +1,59 @@ +path('boboko') + ->brandName('Boboko') + ->brandLogo(asset('logos/core/boboko-logo.svg')) + ->darkModeBrandLogo(asset('logos/core/boboko-logo-white.svg')) + ->login(Login::class) + ->navigationItems([ + NavigationItem::make("Lucent") + ->url("/lucent") + ->icon("heroicon-o-sun") + ->group("Content") + ->sort(1), + ]); + + LunarPanel::extensions([ + StaffResource::class => StaffResourceExtension::class, + ]); + + LunarStaff::addActivitylogExcept([ + 'otp_code', + 'otp_expires_at', + 'password', + 'remember_token', + 'email_verified_at', + 'two_factor_secret', + 'two_factor_recovery_codes', + 'two_factor_confirmed_at', + ]); + + LunarStaff::created(function (LunarStaff $staff) { + Mail::to($staff->email)->send(new InviteMail($staff->first_name)); + }); + } + + public function boot(Panel $panel): void {} +} diff --git a/src/Customer/Extensions/CustomerResourceExtension.php b/src/Customer/Extensions/CustomerResourceExtension.php new file mode 100644 index 0000000..8a959fa --- /dev/null +++ b/src/Customer/Extensions/CustomerResourceExtension.php @@ -0,0 +1,20 @@ + $relation == BaseAddressRelationManager::class + ? AddressRelationManager::class + : $relation, + $relations + ); + } +} diff --git a/src/Customer/Models/Customer.php b/src/Customer/Models/Customer.php new file mode 100644 index 0000000..1be753e --- /dev/null +++ b/src/Customer/Models/Customer.php @@ -0,0 +1,38 @@ + 'datetime', + ]; + + public function getTitleAttribute(): ?string + { + return null; + } + + public function setTitleAttribute($value): void + { + // title is not used + } + + public function getFullNameAttribute(): string + { + return trim( + preg_replace( + "/\s+/", + " ", + "{$this->first_name} {$this->last_name}", + ), + ); + } +} diff --git a/src/Customer/RelationManagers/AddressRelationManager.php b/src/Customer/RelationManagers/AddressRelationManager.php new file mode 100644 index 0000000..b430797 --- /dev/null +++ b/src/Customer/RelationManagers/AddressRelationManager.php @@ -0,0 +1,97 @@ +heading(__('lunarpanel::address.plural_label')) + ->columns([ + TextColumn::make('line_one')->label( + __('lunarpanel::address.table.line_one.label') + ), + TextColumn::make('city')->label( + __('lunarpanel::address.table.city.label') + ), + TextColumn::make('postcode')->label( + __('lunarpanel::address.table.postcode.label') + ), + TextColumn::make('contact_email')->label( + __('lunarpanel::address.table.contact_email.label') + ), + TextColumn::make('contact_phone')->label( + __('lunarpanel::address.table.contact_phone.label') + ), + ]) + ->headerActions([ + CreateAction::make()->form($this->addressForm()), + ]) + ->actions([ + EditAction::make('editAddress') + ->fillForm(fn (AddressContract $record): array => [ + 'line_one' => $record->line_one, + 'city' => $record->city, + 'state' => $record->state, + 'postcode' => $record->postcode, + 'country_id' => $record->country_id, + 'contact_email' => $record->contact_email, + 'contact_phone' => $record->contact_phone, + ]) + ->form($this->addressForm()), + DeleteAction::make('deleteAddress'), + ]); + } + + protected function addressForm(): array // phpcs:ignore + { + return [ + TextInput::make('line_one')->label( + __('lunarpanel::address.form.line_one.label') + )->required(), + Group::make()->schema([ + Select::make('country_id')->label( + __('lunarpanel::address.form.country_id.label') + )->relationship( + name: 'country', + )->getOptionLabelFromRecordUsing(function (Model $record) { + $name = $record->native ?: $record->name; + + return "{$record->emoji} $name"; + }), + TextInput::make('state')->label( + __('lunarpanel::address.form.state.label') + ), + ])->columns(2), + Group::make()->schema([ + TextInput::make('city')->label( + __('lunarpanel::address.form.city.label') + )->required(), + TextInput::make('postcode')->label( + __('lunarpanel::address.form.postcode.label') + ), + ])->columns(2), + Group::make()->schema([ + TextInput::make('contact_email')->label( + __('lunarpanel::address.form.contact_email.label') + ), + TextInput::make('contact_phone')->label( + __('lunarpanel::address.form.contact_phone.label') + ), + ])->columns(2), + ]; + } +} diff --git a/src/Logging/ActivityLogService.php b/src/Logging/ActivityLogService.php new file mode 100644 index 0000000..afc3f6e --- /dev/null +++ b/src/Logging/ActivityLogService.php @@ -0,0 +1,63 @@ +performedOn($subject) + ->causedBy(auth('staff')->user()) + ->withProperties(['attributes' => $attributes]) + ->log('created'); + } + + /** + * Log an update event. $old holds the previous values, $attributes the new ones. + */ + public function updated(Model $subject, array $old, array $attributes): void + { + activity('lunar') + ->performedOn($subject) + ->causedBy(auth('staff')->user()) + ->withProperties(['old' => $old, 'attributes' => $attributes]) + ->log('updated'); + } + + /** + * Log a failed operation. $attributes provides context (e.g. error message, service). + */ + public function failed(Model $subject, array $attributes): void + { + activity('lunar') + ->performedOn($subject) + ->causedBy(auth('staff')->user()) + ->withProperties(['attributes' => $attributes]) + ->log('failed'); + } + + /** + * Log a deletion event. $attributes provides context (e.g. reason, name). + */ + public function deleted(Model $subject, array $attributes): void + { + activity('lunar') + ->performedOn($subject) + ->causedBy(auth('staff')->user()) + ->withProperties(['attributes' => $attributes]) + ->log('deleted'); + } +} diff --git a/src/Notification/BaseNotification.php b/src/Notification/BaseNotification.php new file mode 100644 index 0000000..c442aff --- /dev/null +++ b/src/Notification/BaseNotification.php @@ -0,0 +1,12 @@ +notifications[$key]); + + $this->notifications[$key] = $class; + + if ($alreadyRegistered) { + continue; + } + + Event::listen($class::listensTo(), function (object $event) use ($key) { + $class = $this->notifications[$key] ?? null; + if ($class == null) { + return; + } + try { + $notification = new $class($event); + if (isset($event->delaySeconds) && $event->delaySeconds > 0) { + $notification->delay($event->delaySeconds); + } + $notification->notifiable()->notify($notification); + } catch (\Throwable $e) { + report($e); + } + }); + } + } + + public function unregister(string $key): void + { + unset($this->notifications[$key]); + } + + public function all(): array + { + return $this->notifications; + } +} diff --git a/src/Notification/RegisterableNotification.php b/src/Notification/RegisterableNotification.php new file mode 100644 index 0000000..3d07db2 --- /dev/null +++ b/src/Notification/RegisterableNotification.php @@ -0,0 +1,15 @@ + + */ +final class LazyOption extends Option +{ + /** @var callable(mixed...):(Option) */ + private $callback; + + /** @var array */ + private $arguments; + + /** @var Option|null */ + private $option; + + /** + * @template S + * @param callable(mixed...):(Option) $callback + * @param array $arguments + * + * @return LazyOption + */ + public static function create($callback, array $arguments = []): self + { + return new self($callback, $arguments); + } + + /** + * @param callable(mixed...):(Option) $callback + * @param array $arguments + */ + public function __construct($callback, array $arguments = []) + { + if (!is_callable($callback)) { + throw new \InvalidArgumentException("Invalid callback given"); + } + + $this->callback = $callback; + $this->arguments = $arguments; + } + + public function isDefined(): bool + { + return $this->option()->isDefined(); + } + + public function isEmpty(): bool + { + return $this->option()->isEmpty(); + } + + public function get() + { + return $this->option()->get(); + } + + public function getOrElse($default) + { + return $this->option()->getOrElse($default); + } + + public function getOrCall($callable) + { + return $this->option()->getOrCall($callable); + } + + public function getOrThrow(\Exception $ex) + { + return $this->option()->getOrThrow($ex); + } + + public function orElse(Option $else) + { + return $this->option()->orElse($else); + } + + public function ifDefined($callable) + { + $this->option()->forAll($callable); + } + + public function forAll($callable) + { + return $this->option()->forAll($callable); + } + + public function map($callable) + { + return $this->option()->map($callable); + } + + public function flatMap($callable) + { + return $this->option()->flatMap($callable); + } + + public function filter($callable) + { + return $this->option()->filter($callable); + } + + public function filterNot($callable) + { + return $this->option()->filterNot($callable); + } + + public function select($value) + { + return $this->option()->select($value); + } + + public function reject($value) + { + return $this->option()->reject($value); + } + + /** @return Traversable */ + public function getIterator(): Traversable + { + return $this->option()->getIterator(); + } + + public function foldLeft($initialValue, $callable) + { + return $this->option()->foldLeft($initialValue, $callable); + } + + public function foldRight($initialValue, $callable) + { + return $this->option()->foldRight($initialValue, $callable); + } + + /** @return Option */ + private function option(): Option + { + if (null === $this->option) { + /** @var mixed */ + $option = call_user_func_array($this->callback, $this->arguments); + if ($option instanceof Option) { + $this->option = $option; + } else { + throw new \RuntimeException( + sprintf("Expected instance of %s", Option::class), + ); + } + } + + return $this->option; + } +} diff --git a/src/Option/None.php b/src/Option/None.php new file mode 100644 index 0000000..59b16cc --- /dev/null +++ b/src/Option/None.php @@ -0,0 +1,116 @@ + + */ +final class None extends Option +{ + /** @var None|null */ + private static $instance; + + /** @return None */ + public static function create(): self + { + if (null === self::$instance) { + self::$instance = new self(); + } + + return self::$instance; + } + + public function get() + { + throw new \RuntimeException("None has no value."); + } + + public function getOrCall($callable) + { + return $callable(); + } + + public function getOrElse($default) + { + return $default; + } + + public function getOrThrow(\Exception $ex) + { + throw $ex; + } + + public function isEmpty(): bool + { + return true; + } + + public function isDefined(): bool + { + return false; + } + + public function orElse(Option $else) + { + return $else; + } + + public function ifDefined($callable) + { + // no-op + } + + public function forAll($callable) + { + return $this; + } + + public function map($callable) + { + return $this; + } + + public function flatMap($callable) + { + return $this; + } + + public function filter($callable) + { + return $this; + } + + public function filterNot($callable) + { + return $this; + } + + public function select($value) + { + return $this; + } + + public function reject($value) + { + return $this; + } + + public function getIterator(): EmptyIterator + { + return new EmptyIterator(); + } + + public function foldLeft($initialValue, $callable) + { + return $initialValue; + } + + public function foldRight($initialValue, $callable) + { + return $initialValue; + } + + private function __construct() {} +} diff --git a/src/Option/Option.php b/src/Option/Option.php new file mode 100644 index 0000000..52caa83 --- /dev/null +++ b/src/Option/Option.php @@ -0,0 +1,241 @@ + + */ +abstract class Option implements IteratorAggregate +{ + /** + * @template S + * + * @param S $value + * @param S $noneValue + * + * @return Option + */ + public static function fromValue($value, $noneValue = null) + { + if ($value === $noneValue) { + return None::create(); + } + + return new Some($value); + } + + /** + * @template S + * + * @param array|ArrayAccess|null $array + * @param string|int|null $key + * + * @return Option + */ + public static function fromArraysValue($array, $key) + { + if ( + $key === null || + !(is_array($array) || $array instanceof ArrayAccess) || + !isset($array[$key]) + ) { + return None::create(); + } + + return new Some($array[$key]); + } + + /** + * @template S + * + * @param callable $callback + * @param array $arguments + * @param S $noneValue + * + * @return LazyOption + */ + public static function fromReturn( + $callback, + array $arguments = [], + $noneValue = null, + ) { + return new LazyOption(static function () use ( + $callback, + $arguments, + $noneValue, + ) { + /** @var mixed */ + $return = call_user_func_array($callback, $arguments); + + if ($return === $noneValue) { + return None::create(); + } + + return new Some($return); + }); + } + + /** + * @template S + * + * @param Option|callable|S $value + * @param S $noneValue + * + * @return Option|LazyOption + */ + public static function ensure($value, $noneValue = null) + { + if ($value instanceof self) { + return $value; + } elseif (is_callable($value)) { + return new LazyOption(static function () use ($value, $noneValue) { + /** @var mixed */ + $return = $value(); + + if ($return instanceof self) { + return $return; + } else { + return self::fromValue($return, $noneValue); + } + }); + } else { + return self::fromValue($value, $noneValue); + } + } + + /** + * @template S + * + * @param callable $callback + * @param mixed $noneValue + * + * @return callable + */ + public static function lift($callback, $noneValue = null) + { + return static function () use ($callback, $noneValue) { + /** @var array */ + $args = func_get_args(); + + $reduced_args = array_reduce( + $args, + /** @param bool $status */ + static function ($status, self $o) { + return $o->isEmpty() ? true : $status; + }, + false, + ); + + if ($reduced_args) { + return None::create(); + } + + $args = array_map(static function (self $o) { + return $o->get(); + }, $args); + + return self::ensure( + call_user_func_array($callback, $args), + $noneValue, + ); + }; + } + + /** @return T */ + abstract public function get(); + + /** + * @template S + * @param S $default + * @return T|S + */ + abstract public function getOrElse($default); + + /** + * @template S + * @param callable():S $callable + * @return T|S + */ + abstract public function getOrCall($callable); + + /** @return T */ + abstract public function getOrThrow(\Exception $ex); + + abstract public function isEmpty(): bool; + + abstract public function isDefined(): bool; + + /** + * @param Option $else + * @return Option + */ + abstract public function orElse(self $else); + + /** @deprecated Use forAll() instead. */ + abstract public function ifDefined($callable); + + /** + * @param callable(T):mixed $callable + * @return Option + */ + abstract public function forAll($callable); + + /** + * @template S + * @param callable(T):S $callable + * @return Option + */ + abstract public function map($callable); + + /** + * @template S + * @param callable(T):Option $callable + * @return Option + */ + abstract public function flatMap($callable); + + /** + * @param callable(T):bool $callable + * @return Option + */ + abstract public function filter($callable); + + /** + * @param callable(T):bool $callable + * @return Option + */ + abstract public function filterNot($callable); + + /** + * @param T $value + * @return Option + */ + abstract public function select($value); + + /** + * @param T $value + * @return Option + */ + abstract public function reject($value); + + /** + * @template S + * @param S $initialValue + * @param callable(S, T):S $callable + * @return S + */ + abstract public function foldLeft($initialValue, $callable); + + /** + * @template S + * @param S $initialValue + * @param callable(T, S):S $callable + * @return S + */ + abstract public function foldRight($initialValue, $callable); +} diff --git a/src/Option/Some.php b/src/Option/Some.php new file mode 100644 index 0000000..e3d4739 --- /dev/null +++ b/src/Option/Some.php @@ -0,0 +1,150 @@ + + */ +final class Some extends Option +{ + /** @var T */ + private $value; + + /** @param T $value */ + public function __construct($value) + { + $this->value = $value; + } + + /** + * @template U + * @param U $value + * @return Some + */ + public static function create($value): self + { + return new self($value); + } + + public function isDefined(): bool + { + return true; + } + + public function isEmpty(): bool + { + return false; + } + + public function get() + { + return $this->value; + } + + public function getOrElse($default) + { + return $this->value; + } + + public function getOrCall($callable) + { + return $this->value; + } + + public function getOrThrow(\Exception $ex) + { + return $this->value; + } + + public function orElse(Option $else): Some + { + return $this; + } + + public function ifDefined($callable): void + { + $this->forAll($callable); + } + + public function forAll($callable): Some + { + $callable($this->value); + + return $this; + } + + public function map($callable): Some + { + return new self($callable($this->value)); + } + + public function flatMap($callable): Option + { + /** @var mixed */ + $rs = $callable($this->value); + if (!$rs instanceof Option) { + throw new \RuntimeException( + "Callables passed to flatMap() must return an Option. Maybe you should use map() instead?", + ); + } + + return $rs; + } + + public function filter($callable) + { + if (true === $callable($this->value)) { + return $this; + } + + return None::create(); + } + + public function filterNot($callable) + { + if (false === $callable($this->value)) { + return $this; + } + + return None::create(); + } + + public function select($value) + { + if ($this->value === $value) { + return $this; + } + + return None::create(); + } + + public function reject($value) + { + if ($this->value === $value) { + return None::create(); + } + + return $this; + } + + /** @return ArrayIterator */ + public function getIterator(): ArrayIterator + { + return new ArrayIterator([$this->value]); + } + + public function foldLeft($initialValue, $callable): S + { + return $callable($initialValue, $this->value); + } + + public function foldRight($initialValue, $callable): S + { + return $callable($this->value, $initialValue); + } +} diff --git a/src/Providers/AuthServiceProvider.php b/src/Providers/AuthServiceProvider.php new file mode 100644 index 0000000..c1fea0b --- /dev/null +++ b/src/Providers/AuthServiceProvider.php @@ -0,0 +1,47 @@ + StaffResourceExtension::class, + ]); + + LunarStaff::addActivitylogExcept([ + 'otp_code', + 'otp_expires_at', + 'password', + 'remember_token', + 'email_verified_at', + 'two_factor_secret', + 'two_factor_recovery_codes', + 'two_factor_confirmed_at', + ]); + + LunarCustomer::addActivitylogExcept([ + 'otp_code', + 'otp_expires_at', + ]); + + LunarStaff::created(function (LunarStaff $staff) { + Mail::to($staff->email)->send(new InviteMail($staff->first_name)); + }); + + if ($this->app->runningInConsole()) { + $this->app->booted(fn () => $this->commands([CreateAdminCommand::class])); + } + } +} diff --git a/src/Providers/CoreServiceProvider.php b/src/Providers/CoreServiceProvider.php new file mode 100644 index 0000000..11db2d9 --- /dev/null +++ b/src/Providers/CoreServiceProvider.php @@ -0,0 +1,28 @@ +loadViewsFrom(__DIR__ . '/../../resources/views', 'core'); + $this->loadMigrationsFrom(__DIR__ . '/../../database/migrations'); + + $this->publishes([ + __DIR__ . '/../../resources/logos' => public_path('logos/core'), + ], 'core-assets'); + + if ($this->app->runningInConsole()) { + $this->commands([AnonymizeCommand::class, ExportCommand::class, ExportCleanupCommand::class, ImportCommand::class]); + } + } +} diff --git a/src/Providers/CustomerServiceProvider.php b/src/Providers/CustomerServiceProvider.php new file mode 100644 index 0000000..bc72d3a --- /dev/null +++ b/src/Providers/CustomerServiceProvider.php @@ -0,0 +1,23 @@ + CustomerResourceExtension::class, + ]); + + ModelManifest::replace(LunarCustomer::class, Customer::class); + } +} diff --git a/src/ResultType/Error.php b/src/ResultType/Error.php new file mode 100644 index 0000000..f6b457b --- /dev/null +++ b/src/ResultType/Error.php @@ -0,0 +1,112 @@ + + */ +final class Error extends Result +{ + /** + * @var E + */ + private $value; + + /** + * Internal constructor for an error value. + * + * @param E $value + * + * @return void + */ + private function __construct($value) + { + $this->value = $value; + } + + /** + * Create a new error value. + * + * @template F + * + * @param F $value + * + * @return \Modules\Core\ResultType\Result + */ + public static function create($value): Error + { + return new self($value); + } + + /** + * Get the success option value. + * + * @return \Modules\Core\Option\Option + */ + public function success() + { + return None::create(); + } + + /** + * Map over the success value. + * + * @template S + * + * @param callable(T):S $f + * + * @return \Modules\Core\ResultType\Result + */ + public function map(callable $f): Result + { + return self::create($this->value); + } + + /** + * Flat map over the success value. + * + * @template S + * @template F + * + * @param callable(T):\Modules\Core\ResultType\Result $f + * + * @return \Modules\Core\ResultType\Result + */ + public function flatMap(callable $f): Result + { + /** @var \Modules\Core\ResultType\Result */ + return self::create($this->value); + } + + /** + * Get the error option value. + * + * @return \Modules\Core\Option\Option + */ + public function error(): Some + { + return Some::create($this->value); + } + + /** + * Map over the error value. + * + * @template F + * + * @param callable(E):F $f + * + * @return \Modules\Core\ResultType\Result + */ + public function mapError(callable $f): Result + { + return self::create($f($this->value)); + } +} diff --git a/src/ResultType/Result.php b/src/ResultType/Result.php new file mode 100644 index 0000000..9a5646a --- /dev/null +++ b/src/ResultType/Result.php @@ -0,0 +1,60 @@ + + */ + abstract public function success(); + + /** + * Map over the success value. + * + * @template S + * + * @param callable(T):S $f + * + * @return \Modules\Core\ResultType\Result + */ + abstract public function map(callable $f); + + /** + * Flat map over the success value. + * + * @template S + * @template F + * + * @param callable(T):\Modules\Core\ResultType\Result $f + * + * @return \Modules\Core\ResultType\Result + */ + abstract public function flatMap(callable $f); + + /** + * Get the error option value. + * + * @return \Modules\Core\Option\Option + */ + abstract public function error(); + + /** + * Map over the error value. + * + * @template F + * + * @param callable(E):F $f + * + * @return \Modules\Core\ResultType\Result + */ + abstract public function mapError(callable $f); +} diff --git a/src/ResultType/Success.php b/src/ResultType/Success.php new file mode 100644 index 0000000..c36fa97 --- /dev/null +++ b/src/ResultType/Success.php @@ -0,0 +1,111 @@ + + */ +final class Success extends Result +{ + /** + * @var T + */ + private $value; + + /** + * Internal constructor for a success value. + * + * @param T $value + * + * @return void + */ + private function __construct($value) + { + $this->value = $value; + } + + /** + * Create a new error value. + * + * @template S + * + * @param S $value + * + * @return \Modules\Core\ResultType\Result + */ + public static function create($value): Success + { + return new self($value); + } + + /** + * Get the success option value. + * + * @return \Modules\Core\Option\Option + */ + public function success(): Some + { + return Some::create($this->value); + } + + /** + * Map over the success value. + * + * @template S + * + * @param callable(T):S $f + * + * @return \Modules\Core\ResultType\Result + */ + public function map(callable $f): Result + { + return self::create($f($this->value)); + } + + /** + * Flat map over the success value. + * + * @template S + * @template F + * + * @param callable(T):\Modules\Core\ResultType\Result $f + * + * @return \Modules\Core\ResultType\Result + */ + public function flatMap(callable $f) + { + return $f($this->value); + } + + /** + * Get the error option value. + * + * @return \Modules\Core\Option\Option + */ + public function error() + { + return None::create(); + } + + /** + * Map over the error value. + * + * @template F + * + * @param callable(E):F $f + * + * @return \Modules\Core\ResultType\Result + */ + public function mapError(callable $f): Result + { + return self::create($this->value); + } +}