This commit is contained in:
Konstantinos Arvanitakis
2026-07-01 18:35:39 +03:00
commit 9f58a36c82
44 changed files with 3848 additions and 0 deletions
@@ -0,0 +1,19 @@
<?php
namespace Modules\Core\Auth\Extensions;
use Filament\Forms\Form;
use Lunar\Admin\Support\Extending\ResourceExtension;
class StaffResourceExtension extends ResourceExtension
{
public function extendForm(Form $form): Form
{
$schema = collect($form->getComponents())
->reject(fn ($component) => method_exists($component, 'getName') && $component->getName() == 'password')
->values()
->all();
return $form->schema($schema);
}
}
+97
View File
@@ -0,0 +1,97 @@
<?php
namespace Modules\Core\Auth\Filament\Pages;
use DanHarrin\LivewireRateLimiting\Exceptions\TooManyRequestsException;
use DanHarrin\LivewireRateLimiting\WithRateLimiting;
use Filament\Facades\Filament;
use Filament\Models\Contracts\FilamentUser;
use Filament\Pages\SimplePage;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Validation\ValidationException;
use Modules\Core\Auth\Services\OtpService;
class Login extends SimplePage
{
use WithRateLimiting;
protected static string $view = 'core::auth.filament.pages.login';
public ?string $email = '';
public ?string $otp = '';
public bool $otpSent = false;
private OtpService $otpService;
public function boot(OtpService $otpService): void
{
$this->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';
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace Modules\Core\Auth\Mail;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
class CustomerOtpMail extends Mailable
{
public function __construct(
public readonly string $name,
public readonly string $code,
) {}
public function envelope(): Envelope
{
return new Envelope(subject: 'Ο κωδικός σύνδεσης σου');
}
public function content(): Content
{
return new Content(view: 'core::auth.mail.customer-otp');
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace Modules\Core\Auth\Mail;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
class InviteMail extends Mailable
{
public function __construct(
public readonly string $name,
) {}
public function envelope(): Envelope
{
return new Envelope(subject: 'You have been invited');
}
public function content(): Content
{
return new Content(view: 'core::auth.mail.invite');
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace Modules\Core\Auth\Mail;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
class OtpMail extends Mailable
{
public function __construct(
public readonly string $name,
public readonly string $code,
) {}
public function envelope(): Envelope
{
return new Envelope(subject: 'Your login code');
}
public function content(): Content
{
return new Content(view: 'core::auth.mail.otp');
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace Modules\Core\Auth\Models;
use Lunar\Admin\Models\Staff as ModelsStaff;
class Staff extends ModelsStaff
{
protected $fillable = [
'first_name',
'last_name',
'admin',
'email',
'otp_code',
'otp_expires_at',
];
protected $casts = [
'admin' => 'bool',
'email_verified_at' => 'datetime',
'password' => 'hashed',
'otp_expires_at' => 'datetime',
];
}
+61
View File
@@ -0,0 +1,61 @@
<?php
namespace Modules\Core\Auth\Services;
use Illuminate\Support\Facades\Mail;
use Lunar\Models\Contracts\Customer;
use Lunar\Facades\ModelManifest;
use Modules\Core\Auth\Mail\CustomerOtpMail;
class CustomerOtpService
{
private const EXPIRY_MINUTES = 10;
private const CODE_LENGTH = 6;
public function generateAndSend(string $email): bool
{
$customer = $this->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();
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
namespace Modules\Core\Auth\Services;
use Illuminate\Support\Facades\Mail;
use Modules\Core\Auth\Mail\OtpMail;
use Modules\Core\Auth\Models\Staff;
class OtpService
{
private const EXPIRY_MINUTES = 10;
private const CODE_LENGTH = 6;
public function generateAndSend(string $email): bool
{
$staff = Staff::where('email', $email)->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;
}
}
+65
View File
@@ -0,0 +1,65 @@
<?php
namespace Modules\Core\Command;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
class AnonymizeCommand extends Command
{
protected $signature = "boboko:anonymize";
protected $description = "Anonymize personal data in users and lunar_customers";
public function handle(): void
{
if (!app()->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.");
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace Modules\Core\Command;
use Lunar\Admin\Console\Commands\MakeLunarAdminCommand;
use function Laravel\Prompts\text;
class CreateAdminCommand extends MakeLunarAdminCommand
{
protected $signature = 'lunar:create-admin
{--firstname= : The first name of the user}
{--lastname= : The last name of the user}
{--email= : A valid and unique email address}';
protected function getUserData(): array
{
return [
'first_name' => $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,
];
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace Modules\Core\Command;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
class ExportCleanupCommand extends Command
{
protected $signature = "boboko:export:cleanup {--keep=0 : Number of most recent exports to keep}";
protected $description = "Delete export archives";
public function handle(): void
{
$exportDir = Storage::disk("local")->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).");
}
}
+112
View File
@@ -0,0 +1,112 @@
<?php
namespace Modules\Core\Command;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
use Modules\Core\ResultType\Error;
use Modules\Core\ResultType\Result;
use Modules\Core\ResultType\Success;
use ZipArchive;
class ExportCommand extends Command
{
protected $signature = "boboko:export";
protected $description = "Export data and files";
public function handle(): void
{
$db = config("database.connections.pgsql");
$disk = Storage::disk("local");
$exportDir = $disk->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<string, string> */
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<string, string> */
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);
}
}
+167
View File
@@ -0,0 +1,167 @@
<?php
namespace Modules\Core\Command;
use Illuminate\Console\Command;
use Illuminate\Contracts\Filesystem\Filesystem;
use Illuminate\Support\Facades\Storage;
use Modules\Core\ResultType\Error;
use Modules\Core\ResultType\Result;
use Modules\Core\ResultType\Success;
use ZipArchive;
class ImportCommand extends Command
{
protected $signature = "boboko:import";
protected $description = "Import data and files from an export archive";
public function handle(): void
{
if (!app()->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<string, string> */
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<string, string> */
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<string, string> */
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);
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
namespace Modules\Core;
use Filament\Contracts\Plugin;
use Filament\Navigation\NavigationItem;
use Filament\Panel;
use Illuminate\Support\Facades\Mail;
use Lunar\Admin\Filament\Resources\StaffResource;
use Lunar\Admin\Models\Staff as LunarStaff;
use Lunar\Admin\Support\Facades\LunarPanel;
use Modules\Core\Auth\Extensions\StaffResourceExtension;
use Modules\Core\Auth\Filament\Pages\Login;
use Modules\Core\Auth\Mail\InviteMail;
class CorePlugin implements Plugin
{
public function getId(): string
{
return 'core';
}
public function register(Panel $panel): void
{
$panel->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 {}
}
@@ -0,0 +1,20 @@
<?php
namespace Modules\Core\Customer\Extensions;
use Lunar\Admin\Filament\Resources\CustomerResource\RelationManagers\AddressRelationManager as BaseAddressRelationManager;
use Lunar\Admin\Support\Extending\BaseExtension;
use Modules\Core\Customer\RelationManagers\AddressRelationManager;
class CustomerResourceExtension extends BaseExtension
{
public function getRelations(array $relations): array
{
return array_map(
fn ($relation) => $relation == BaseAddressRelationManager::class
? AddressRelationManager::class
: $relation,
$relations
);
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace Modules\Core\Customer\Models;
use Illuminate\Auth\Authenticatable;
use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
use Illuminate\Foundation\Auth\Access\Authorizable;
class Customer extends \Lunar\Models\Customer implements AuthenticatableContract
{
use Authenticatable;
use Authorizable;
protected $casts = [
'otp_expires_at' => '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}",
),
);
}
}
@@ -0,0 +1,97 @@
<?php
namespace Modules\Core\Customer\RelationManagers;
use Filament\Forms\Components\Group;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Tables\Actions\CreateAction;
use Filament\Tables\Actions\DeleteAction;
use Filament\Tables\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Model;
use Lunar\Admin\Filament\Resources\CustomerResource\RelationManagers\AddressRelationManager as BaseAddressRelationManager;
use Lunar\Models\Contracts\Address as AddressContract;
class AddressRelationManager extends BaseAddressRelationManager
{
public function getDefaultTable(Table $table): Table
{
return $table
->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),
];
}
}
+63
View File
@@ -0,0 +1,63 @@
<?php
namespace Modules\Core\Logging;
use Illuminate\Database\Eloquent\Model;
/**
* Thin wrapper around Spatie Activity Log that standardises the log channel,
* actor (authenticated staff member), and property shape for all domain events.
*
* All logs are written to the 'lunar' channel. The subject is always an
* Eloquent model, and the actor is resolved from the 'staff' guard at call time.
*/
class ActivityLogService
{
/**
* Log a creation event. $attributes describes the initial state.
*/
public function created(Model $subject, array $attributes): void
{
activity('lunar')
->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');
}
}
+12
View File
@@ -0,0 +1,12 @@
<?php
namespace Modules\Core\Notification;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Notification;
abstract class BaseNotification extends Notification implements RegisterableNotification, ShouldQueue
{
use Queueable;
}
+63
View File
@@ -0,0 +1,63 @@
<?php
namespace Modules\Core\Notification;
use Illuminate\Support\Facades\Event;
class NotificationRegistry
{
private static ?self $instance = null;
private array $notifications = [];
private function __construct() {}
public static function get(): static
{
if (static::$instance == null) {
static::$instance = new static();
}
return static::$instance;
}
public function register(array $notifications): void
{
foreach ($notifications as $class) {
$key = $class::getKey();
$alreadyRegistered = isset($this->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;
}
}
@@ -0,0 +1,15 @@
<?php
namespace Modules\Core\Notification;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Notifications\AnonymousNotifiable;
interface RegisterableNotification
{
public static function getKey(): string;
public static function listensTo(): string;
public function notifiable(): AnonymousNotifiable|Model;
}
+157
View File
@@ -0,0 +1,157 @@
<?php
namespace Modules\Core\Option;
use Traversable;
/**
* @template T
*
* @extends Option<T>
*/
final class LazyOption extends Option
{
/** @var callable(mixed...):(Option<T>) */
private $callback;
/** @var array<int, mixed> */
private $arguments;
/** @var Option<T>|null */
private $option;
/**
* @template S
* @param callable(mixed...):(Option<S>) $callback
* @param array<int, mixed> $arguments
*
* @return LazyOption<S>
*/
public static function create($callback, array $arguments = []): self
{
return new self($callback, $arguments);
}
/**
* @param callable(mixed...):(Option<T>) $callback
* @param array<int, mixed> $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<T> */
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<T> */
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;
}
}
+116
View File
@@ -0,0 +1,116 @@
<?php
namespace Modules\Core\Option;
use EmptyIterator;
/**
* @extends Option<mixed>
*/
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() {}
}
+241
View File
@@ -0,0 +1,241 @@
<?php
namespace Modules\Core\Option;
use ArrayAccess;
use IteratorAggregate;
/**
* @template T
*
* @implements IteratorAggregate<T>
*/
abstract class Option implements IteratorAggregate
{
/**
* @template S
*
* @param S $value
* @param S $noneValue
*
* @return Option<S>
*/
public static function fromValue($value, $noneValue = null)
{
if ($value === $noneValue) {
return None::create();
}
return new Some($value);
}
/**
* @template S
*
* @param array<string|int,S>|ArrayAccess<string|int,S>|null $array
* @param string|int|null $key
*
* @return Option<S>
*/
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<S>
*/
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<S>|callable|S $value
* @param S $noneValue
*
* @return Option<S>|LazyOption<S>
*/
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<int, mixed> */
$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<T> $else
* @return Option<T>
*/
abstract public function orElse(self $else);
/** @deprecated Use forAll() instead. */
abstract public function ifDefined($callable);
/**
* @param callable(T):mixed $callable
* @return Option<T>
*/
abstract public function forAll($callable);
/**
* @template S
* @param callable(T):S $callable
* @return Option<S>
*/
abstract public function map($callable);
/**
* @template S
* @param callable(T):Option<S> $callable
* @return Option<S>
*/
abstract public function flatMap($callable);
/**
* @param callable(T):bool $callable
* @return Option<T>
*/
abstract public function filter($callable);
/**
* @param callable(T):bool $callable
* @return Option<T>
*/
abstract public function filterNot($callable);
/**
* @param T $value
* @return Option<T>
*/
abstract public function select($value);
/**
* @param T $value
* @return Option<T>
*/
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);
}
+150
View File
@@ -0,0 +1,150 @@
<?php
namespace Modules\Core\Option;
use ArrayIterator;
use Exception;
/**
* @template T
*
* @extends Option<T>
*/
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<U>
*/
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<int, T> */
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);
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
namespace Modules\Core\Providers;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\ServiceProvider;
use Lunar\Admin\Filament\Resources\StaffResource;
use Lunar\Admin\Models\Staff as LunarStaff;
use Lunar\Admin\Support\Facades\LunarPanel;
use Lunar\Models\Customer as LunarCustomer;
use Modules\Core\Auth\Extensions\StaffResourceExtension;
use Modules\Core\Auth\Mail\InviteMail;
use Modules\Core\Command\CreateAdminCommand;
class AuthServiceProvider extends ServiceProvider
{
public function boot(): void
{
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',
]);
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]));
}
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace Modules\Core\Providers;
use Illuminate\Support\ServiceProvider;
use Modules\Core\Command\AnonymizeCommand;
use Modules\Core\Command\ExportCleanupCommand;
use Modules\Core\Command\ExportCommand;
use Modules\Core\Command\ImportCommand;
class CoreServiceProvider extends ServiceProvider
{
public function register(): void {}
public function boot(): void
{
$this->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]);
}
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace Modules\Core\Providers;
use Illuminate\Support\ServiceProvider;
use Lunar\Admin\Filament\Resources\CustomerResource;
use Lunar\Admin\Support\Facades\LunarPanel;
use Lunar\Facades\ModelManifest;
use Lunar\Models\Contracts\Customer as LunarCustomer;
use Modules\Core\Customer\Extensions\CustomerResourceExtension;
use Modules\Core\Customer\Models\Customer;
class CustomerServiceProvider extends ServiceProvider
{
public function boot(): void
{
LunarPanel::extensions([
CustomerResource::class => CustomerResourceExtension::class,
]);
ModelManifest::replace(LunarCustomer::class, Customer::class);
}
}
+112
View File
@@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
namespace Modules\Core\ResultType;
use Modules\Core\Option\None;
use Modules\Core\Option\Some;
/**
* @template T
* @template E
*
* @extends \Modules\Core\ResultType\Result<T,E>
*/
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<T,F>
*/
public static function create($value): Error
{
return new self($value);
}
/**
* Get the success option value.
*
* @return \Modules\Core\Option\Option<T>
*/
public function success()
{
return None::create();
}
/**
* Map over the success value.
*
* @template S
*
* @param callable(T):S $f
*
* @return \Modules\Core\ResultType\Result<S,E>
*/
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<S,F> $f
*
* @return \Modules\Core\ResultType\Result<S,F>
*/
public function flatMap(callable $f): Result
{
/** @var \Modules\Core\ResultType\Result<S,F> */
return self::create($this->value);
}
/**
* Get the error option value.
*
* @return \Modules\Core\Option\Option<E>
*/
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<T,F>
*/
public function mapError(callable $f): Result
{
return self::create($f($this->value));
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Modules\Core\ResultType;
/**
* @template T
* @template E
*/
abstract class Result
{
/**
* Get the success option value.
*
* @return \Modules\Core\Option\Option<T>
*/
abstract public function success();
/**
* Map over the success value.
*
* @template S
*
* @param callable(T):S $f
*
* @return \Modules\Core\ResultType\Result<S,E>
*/
abstract public function map(callable $f);
/**
* Flat map over the success value.
*
* @template S
* @template F
*
* @param callable(T):\Modules\Core\ResultType\Result<S,F> $f
*
* @return \Modules\Core\ResultType\Result<S,F>
*/
abstract public function flatMap(callable $f);
/**
* Get the error option value.
*
* @return \Modules\Core\Option\Option<E>
*/
abstract public function error();
/**
* Map over the error value.
*
* @template F
*
* @param callable(E):F $f
*
* @return \Modules\Core\ResultType\Result<T,F>
*/
abstract public function mapError(callable $f);
}
+111
View File
@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace Modules\Core\ResultType;
use Modules\Core\Option\None;
use Modules\Core\Option\Some;
/**
* @template T
* @template E
*
* @extends \Modules\Core\ResultType\Result<T,E>
*/
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<S,E>
*/
public static function create($value): Success
{
return new self($value);
}
/**
* Get the success option value.
*
* @return \Modules\Core\Option\Option<T>
*/
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<S,E>
*/
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<S,F> $f
*
* @return \Modules\Core\ResultType\Result<S,F>
*/
public function flatMap(callable $f)
{
return $f($this->value);
}
/**
* Get the error option value.
*
* @return \Modules\Core\Option\Option<E>
*/
public function error()
{
return None::create();
}
/**
* Map over the error value.
*
* @template F
*
* @param callable(E):F $f
*
* @return \Modules\Core\ResultType\Result<T,F>
*/
public function mapError(callable $f): Result
{
return self::create($this->value);
}
}