Compare commits

..

No commits in common. "main" and "v1.0.3" have entirely different histories.
main ... v1.0.3

31 changed files with 1139 additions and 8015 deletions

50
.gitignore vendored
View file

@ -1,44 +1,40 @@
# ── Dependencies ── # Dependencies
/node_modules/
/vendor/
/schneespur/node_modules/
/schneespur/vendor/ /schneespur/vendor/
/node_modules/
# ── Local env (keep .env.example tracked) ── # Environment
.env .env
.env.* .env.backup
!.env.example .env.production
# ── Laravel runtime ── # IDE / Editor
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Laravel
/schneespur/storage/logs/*.log /schneespur/storage/logs/*.log
/schneespur/storage/framework/cache/* /schneespur/storage/framework/cache/*
/schneespur/storage/framework/sessions/* /schneespur/storage/framework/sessions/*
/schneespur/storage/framework/views/* /schneespur/storage/framework/views/*
/schneespur/storage/testing/ /schneespur/storage/testing/
# ── Build artifacts ── # Build artifacts
/schneespur/public/build/ /schneespur/public/build/
/schneespur/public/hot /schneespur/public/hot
/release/
# ── Internal dev files (not part of distributable app) ── # Testing
/build.sh
/moduldoku.md
/package-lock.json
# ── Test caches ──
.phpunit.result.cache .phpunit.result.cache
.phpunit.cache/ .phpunit.cache/
# ── IDE / OS ── # Release builds
.idea/ /release/
.vscode/
*.swp
.DS_Store
Thumbs.db
# ── Local tooling ── # Misc
.gsd *.bak
.gsd-id *.orig
.mcp.json
.bg-shell/

View file

@ -18,10 +18,9 @@ APP_MAINTENANCE_DRIVER=file
BCRYPT_ROUNDS=12 BCRYPT_ROUNDS=12
LOG_CHANNEL=stack LOG_CHANNEL=stack
LOG_STACK=daily LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=warning LOG_LEVEL=debug
LOG_DAILY_DAYS=14
DB_CONNECTION=mysql DB_CONNECTION=mysql
DB_HOST=127.0.0.1 DB_HOST=127.0.0.1

View file

@ -1 +1 @@
1.0.5 1.0.3

View file

@ -5,7 +5,6 @@ namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Setting; use App\Models\Setting;
use App\Services\Extension\DashboardWidgetRegistry; use App\Services\Extension\DashboardWidgetRegistry;
use App\Services\Extension\FilterRegistry;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\View\View; use Illuminate\View\View;
@ -18,10 +17,9 @@ class DashboardController extends Controller
return redirect()->route('admin.dashboard'); return redirect()->route('admin.dashboard');
} }
public function index(DashboardWidgetRegistry $widgetRegistry, FilterRegistry $filterRegistry): View public function index(DashboardWidgetRegistry $widgetRegistry): View
{ {
$widgets = $widgetRegistry->getWidgets(); $widgets = $widgetRegistry->getWidgets();
$widgets = $filterRegistry->apply('schneespur.dashboard.kpis', $widgets);
return view('admin.dashboard', compact('widgets')); return view('admin.dashboard', compact('widgets'));
} }

View file

@ -3,7 +3,6 @@
namespace App\Http\Controllers\Admin; namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Controllers\DsgvoOnboardingController;
use App\Models\DsgvoConfirmation; use App\Models\DsgvoConfirmation;
use App\Models\Setting; use App\Models\Setting;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@ -19,7 +18,7 @@ class DsgvoAdminController extends Controller
$version = (int) Setting::get('dsgvo_template_version', 1); $version = (int) Setting::get('dsgvo_template_version', 1);
if ($markdown === null) { if ($markdown === null) {
$markdown = view(DsgvoOnboardingController::resolveDefaultTemplateView())->render(); $markdown = view('dsgvo.default-template')->render();
} }
$previewHtml = Str::markdown($this->replacePlaceholders($markdown), ['html_input' => 'strip']); $previewHtml = Str::markdown($this->replacePlaceholders($markdown), ['html_input' => 'strip']);
@ -83,7 +82,25 @@ class DsgvoAdminController extends Controller
private function replacePlaceholders(string $text): string private function replacePlaceholders(string $text): string
{ {
return dsgvo_apply_company_placeholders($text); $companyName = Setting::get('company_name', '');
$street = Setting::get('company_street', '');
$zip = Setting::get('company_zip', '');
$city = Setting::get('company_city', '');
$email = Setting::get('company_email', '');
$dpo = Setting::get('dpo_contact', '');
$dpoEmail = Setting::get('dpo_email', '');
$address = trim("$street, $zip $city", ', ');
$replacements = [
'[Firmenname eintragen]' => $companyName ?: '[Firmenname eintragen]',
'[Adresse eintragen]' => $address ?: '[Adresse eintragen]',
'[E-Mail-Adresse eintragen]' => $email ?: '[E-Mail-Adresse eintragen]',
'[DPO-E-Mail-Adresse eintragen]' => $dpoEmail ?: '[DPO-E-Mail-Adresse eintragen]',
'[Datenschutzbeauftragter / Ansprechpartner eintragen]' => $dpo ?: '[Datenschutzbeauftragter / Ansprechpartner eintragen]',
];
return str_replace(array_keys($replacements), array_values($replacements), $text);
} }
public function showConfirmation(int $id): View public function showConfirmation(int $id): View

View file

@ -66,22 +66,32 @@ class DsgvoOnboardingController extends Controller
$version = (int) Setting::get('dsgvo_template_version', 1); $version = (int) Setting::get('dsgvo_template_version', 1);
if ($text === null) { if ($text === null) {
$text = view(self::resolveDefaultTemplateView())->render(); $text = view('dsgvo.default-template')->render();
} }
return [$text, $version]; return [$text, $version];
} }
public static function resolveDefaultTemplateView(): string
{
$locale = app()->getLocale();
$localized = "dsgvo.default-template-{$locale}";
return view()->exists($localized) ? $localized : 'dsgvo.default-template';
}
private function replacePlaceholders(string $text): string private function replacePlaceholders(string $text): string
{ {
return dsgvo_apply_company_placeholders($text); $companyName = Setting::get('company_name', '');
$street = Setting::get('company_street', '');
$zip = Setting::get('company_zip', '');
$city = Setting::get('company_city', '');
$email = Setting::get('company_email', '');
$dpo = Setting::get('dpo_contact', '');
$dpoEmail = Setting::get('dpo_email', '');
$address = trim("$street, $zip $city", ', ');
$replacements = [
'[Firmenname eintragen]' => $companyName ?: '[Firmenname eintragen]',
'[Adresse eintragen]' => $address ?: '[Adresse eintragen]',
'[E-Mail-Adresse eintragen]' => $email ?: '[E-Mail-Adresse eintragen]',
'[DPO-E-Mail-Adresse eintragen]' => $dpoEmail ?: '[DPO-E-Mail-Adresse eintragen]',
'[Datenschutzbeauftragter / Ansprechpartner eintragen]' => $dpo ?: '[Datenschutzbeauftragter / Ansprechpartner eintragen]',
];
return str_replace(array_keys($replacements), array_values($replacements), $text);
} }
} }

View file

@ -129,7 +129,7 @@ class InstallerController extends Controller
{ {
if ($this->preflightChecker->hasCriticalFailures()) { if ($this->preflightChecker->hasCriticalFailures()) {
return redirect()->route('install.preflight') return redirect()->route('install.preflight')
->withErrors(['preflight' => __('install.preflight_has_failures')]); ->withErrors(['preflight' => 'Kritische Voraussetzungen nicht erfüllt.']);
} }
return redirect()->route('install.database'); return redirect()->route('install.database');

View file

@ -4,7 +4,6 @@ namespace App\Listeners;
use App\Events\JobCompleted; use App\Events\JobCompleted;
use App\Mail\JobCompletedMail; use App\Mail\JobCompletedMail;
use App\Services\Extension\FilterRegistry;
use App\Services\NotificationLogService; use App\Services\NotificationLogService;
use App\Services\PdfReportService; use App\Services\PdfReportService;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
@ -16,7 +15,6 @@ class SendJobCompletedNotification implements ShouldQueue
public function __construct( public function __construct(
private NotificationLogService $notificationLogService, private NotificationLogService $notificationLogService,
private PdfReportService $pdfReportService, private PdfReportService $pdfReportService,
private FilterRegistry $filterRegistry,
) {} ) {}
public function handle(JobCompleted $event): void public function handle(JobCompleted $event): void
@ -37,7 +35,6 @@ class SendJobCompletedNotification implements ShouldQueue
} }
$recipients = $this->resolveRecipients($object, $customer); $recipients = $this->resolveRecipients($object, $customer);
$recipients = $this->filterRegistry->apply('schneespur.job.notification.recipients', $recipients, $job);
if (empty($recipients)) { if (empty($recipients)) {
$this->notificationLogService->logSkipped($job, $notificationType, 'skipped_no_email'); $this->notificationLogService->logSkipped($job, $notificationType, 'skipped_no_email');

View file

@ -15,14 +15,12 @@ use App\Services\Diagnostic\DiagnosticManager;
use App\Services\Diagnostic\DiagnosticPayloadSanitizer; use App\Services\Diagnostic\DiagnosticPayloadSanitizer;
use App\Services\Diagnostic\DiagnosticReporterRegistry; use App\Services\Diagnostic\DiagnosticReporterRegistry;
use App\Services\Extension\DashboardWidgetRegistry; use App\Services\Extension\DashboardWidgetRegistry;
use App\Services\Extension\FilterRegistry;
use App\Services\Extension\NavigationRegistry; use App\Services\Extension\NavigationRegistry;
use App\Services\ForecastService; use App\Services\ForecastService;
use App\Services\ModuleManager; use App\Services\ModuleManager;
use App\Services\RetentionService; use App\Services\RetentionService;
use App\Services\SchneespurUpdater; use App\Services\SchneespurUpdater;
use App\Services\SeasonService; use App\Services\SeasonService;
use App\Services\Translation\BrandedTranslator;
use App\Services\Weather\BrightSkyProvider; use App\Services\Weather\BrightSkyProvider;
use App\Services\Weather\MetNorwayProvider; use App\Services\Weather\MetNorwayProvider;
use App\Services\Weather\OpenMeteoApiProvider; use App\Services\Weather\OpenMeteoApiProvider;
@ -48,7 +46,6 @@ class AppServiceProvider extends ServiceProvider
{ {
$this->app->singleton(AlertService::class); $this->app->singleton(AlertService::class);
$this->app->singleton(DashboardWidgetRegistry::class); $this->app->singleton(DashboardWidgetRegistry::class);
$this->app->singleton(FilterRegistry::class);
$this->app->singleton(NavigationRegistry::class); $this->app->singleton(NavigationRegistry::class);
$this->app->singleton(DiagnosticPayloadSanitizer::class); $this->app->singleton(DiagnosticPayloadSanitizer::class);
$this->app->singleton(DiagnosticReporterRegistry::class, fn ($app) => new DiagnosticReporterRegistry($app)); $this->app->singleton(DiagnosticReporterRegistry::class, fn ($app) => new DiagnosticReporterRegistry($app));
@ -67,17 +64,6 @@ class AppServiceProvider extends ServiceProvider
if (empty(config('app.key'))) { if (empty(config('app.key'))) {
config(['app.key' => 'base64:'.base64_encode(random_bytes(32))]); config(['app.key' => 'base64:'.base64_encode(random_bytes(32))]);
} }
$this->app->extend('translator', function ($translator, $app) {
if ($translator instanceof BrandedTranslator) {
return $translator;
}
$branded = new BrandedTranslator($translator->getLoader(), $translator->getLocale());
if ($fallback = $translator->getFallback()) {
$branded->setFallback($fallback);
}
return $branded;
});
} }
/** /**

View file

@ -1,43 +0,0 @@
<?php
namespace App\Services\Extension;
use Illuminate\Support\Facades\Log;
class FilterRegistry
{
private array $hooks = [];
private int $insertionCounter = 0;
public function register(string $hook, callable $callback, int $priority = 100): void
{
$this->hooks[$hook][] = [$priority, $this->insertionCounter++, $callback];
}
public function apply(string $hook, mixed $value, mixed ...$context): mixed
{
if (empty($this->hooks[$hook])) {
return $value;
}
$callbacks = $this->hooks[$hook];
usort($callbacks, fn (array $a, array $b) => $a[0] <=> $b[0] ?: $a[1] <=> $b[1]);
foreach ($callbacks as $entry) {
$previousValue = $value;
try {
$value = $entry[2]($value, ...$context);
} catch (\Throwable $e) {
Log::warning('FilterRegistry: callback failed', [
'hook' => $hook,
'index' => $entry[1],
'error' => $e->getMessage(),
]);
$value = $previousValue;
}
}
return $value;
}
}

View file

@ -8,10 +8,6 @@ class NavigationRegistry extends ExtensionRegistry
{ {
protected array $groups = []; protected array $groups = [];
public function __construct(
private readonly FilterRegistry $filterRegistry,
) {}
public function addGroup(string $key, string $label, int $order = 100): void public function addGroup(string $key, string $label, int $order = 100): void
{ {
$this->groups[$key] = ['key' => $key, 'label' => $label, 'order' => $order]; $this->groups[$key] = ['key' => $key, 'label' => $label, 'order' => $order];
@ -77,6 +73,6 @@ class NavigationRegistry extends ExtensionRegistry
usort($groupItems, fn (array $a, array $b) => $a['order'] <=> $b['order']); usort($groupItems, fn (array $a, array $b) => $a['order'] <=> $b['order']);
} }
return $this->filterRegistry->apply('schneespur.navigation.items', $grouped); return $grouped;
} }
} }

View file

@ -56,7 +56,7 @@ class ModuleManager
$this->modules[$slug] = $manifest; $this->modules[$slug] = $manifest;
Log::debug('ModuleManager: module discovered', [ Log::info('ModuleManager: module discovered', [
'slug' => $slug, 'slug' => $slug,
'version' => $manifest['version'] ?? 'unknown', 'version' => $manifest['version'] ?? 'unknown',
]); ]);
@ -93,13 +93,6 @@ class ModuleManager
continue; continue;
} }
// Reference example module — opt-in only via env var. Ensures the
// bundled dev demo never auto-loads on customer installs even if the
// old folder is still present after upgrading from older releases.
if ($slug === 'example' && ! env('EXAMPLE_MODULE_ENABLED', false)) {
continue;
}
$namespace = $manifest['namespace'] ?? null; $namespace = $manifest['namespace'] ?? null;
$srcPath = ($manifest['path'] ?? '') . '/src'; $srcPath = ($manifest['path'] ?? '') . '/src';
@ -135,7 +128,7 @@ class ModuleManager
$provider->register(); $provider->register();
$provider->boot(); $provider->boot();
Log::debug('ModuleManager: module booted', [ Log::info('ModuleManager: module booted', [
'slug' => $slug, 'slug' => $slug,
'version' => $manifest['version'] ?? 'unknown', 'version' => $manifest['version'] ?? 'unknown',
]); ]);
@ -172,7 +165,7 @@ class ModuleManager
$this->disabledModules[] = $slug; $this->disabledModules[] = $slug;
} }
Log::debug('ModuleManager: module disabled', ['slug' => $slug]); Log::info('ModuleManager: module disabled', ['slug' => $slug]);
return true; return true;
} }

View file

@ -170,18 +170,6 @@ class SchneespurUpdater
throw new RuntimeException('Signatur ungültig — MITM oder beschädigt'); throw new RuntimeException('Signatur ungültig — MITM oder beschädigt');
} }
// Same-version short-circuit first: if the server still serves the
// currently installed release (same counter, same version), this is
// not a rollback — just "you're up to date". The counter check below
// would otherwise misfire after every successful install, because
// the next manifest fetch carries the exact same counter that was
// committed during install.
if ($manifest['version'] === ($state['current_version'] ?? '')) {
$this->writeLastCheck($state, false);
return null;
}
$newCounter = (int) $manifest['counter']; $newCounter = (int) $manifest['counter'];
if ($newCounter <= (int) ($state['last_counter'] ?? 0)) { if ($newCounter <= (int) ($state['last_counter'] ?? 0)) {
throw new RuntimeException( throw new RuntimeException(
@ -189,6 +177,12 @@ class SchneespurUpdater
); );
} }
if ($manifest['version'] === ($state['current_version'] ?? '')) {
$this->writeLastCheck($state, false);
return null;
}
$this->writeLastCheck($state, true, $manifest); $this->writeLastCheck($state, true, $manifest);
return $manifest; return $manifest;
@ -285,44 +279,7 @@ class SchneespurUpdater
$this->validateZipEntries($zip); $this->validateZipEntries($zip);
$prefix = $this->detectCommonPrefix($zip);
if ($prefix === null) {
$zip->extractTo($this->stagingDir); $zip->extractTo($this->stagingDir);
} else {
$this->logPhase('extract', 'stripping_prefix', ['prefix' => $prefix]);
for ($i = 0; $i < $zip->numFiles; $i++) {
$entry = $zip->getNameIndex($i);
if ($entry === false || $entry === '' || $entry === $prefix) {
continue;
}
if (str_starts_with($entry, '__MACOSX/')) {
continue;
}
$relative = substr($entry, strlen($prefix));
if ($relative === '') {
continue;
}
$dest = $this->stagingDir . '/' . $relative;
if (str_ends_with($entry, '/')) {
$this->ensureDirectory($dest);
continue;
}
$this->ensureDirectory(dirname($dest));
$contents = $zip->getFromIndex($i);
if ($contents === false || file_put_contents($dest, $contents) === false) {
$zip->close();
throw new RuntimeException("ZIP-Extraktion fehlgeschlagen: {$entry}");
}
}
}
$zip->close(); $zip->close();
$this->logPhase('extract', 'complete', ['files' => $this->countFiles($this->stagingDir)]); $this->logPhase('extract', 'complete', ['files' => $this->countFiles($this->stagingDir)]);
@ -330,46 +287,6 @@ class SchneespurUpdater
return $this->stagingDir; return $this->stagingDir;
} }
/**
* Detect whether all (non-metadata) ZIP entries share one common
* top-level folder. Returns the prefix (incl. trailing slash) when so,
* null when the ZIP is flat or has mixed top-level entries.
*
* Mirrors the logic in SchneespurModuleInstaller so update ZIPs that
* wrap their content in a versioned folder (the build.sh convention)
* are unwrapped during extraction instead of leaving a stray
* schneespur-X.Y.Z/ subdirectory in the live install.
*/
private function detectCommonPrefix(ZipArchive $zip): ?string
{
$prefix = null;
for ($i = 0; $i < $zip->numFiles; $i++) {
$entry = $zip->getNameIndex($i);
if ($entry === false || $entry === '') {
continue;
}
if (str_starts_with($entry, '__MACOSX/')) {
continue;
}
$slash = strpos($entry, '/');
if ($slash === false) {
return null;
}
$top = substr($entry, 0, $slash + 1);
if ($prefix === null) {
$prefix = $top;
} elseif ($prefix !== $top) {
return null;
}
}
return $prefix;
}
private function validateZipEntries(ZipArchive $zip): void private function validateZipEntries(ZipArchive $zip): void
{ {
$resolvedStaging = realpath($this->stagingDir) ?: $this->stagingDir; $resolvedStaging = realpath($this->stagingDir) ?: $this->stagingDir;

View file

@ -1,26 +0,0 @@
<?php
namespace App\Services\Translation;
use Illuminate\Translation\Translator;
class BrandedTranslator extends Translator
{
public function get($key, array $replace = [], $locale = null, $fallback = true)
{
if (! array_key_exists('app_name', $replace)) {
$replace['app_name'] = $this->resolveAppName();
}
return parent::get($key, $replace, $locale, $fallback);
}
private function resolveAppName(): string
{
try {
return brand();
} catch (\Throwable) {
return (string) config('app.name', 'Schneespur');
}
}
}

View file

@ -23,46 +23,3 @@ function brand(): string
default => 'Schneespur', default => 'Schneespur',
}; };
} }
/**
* Replace company/DPO placeholders in DSGVO/GDPR template markdown.
*
* Knows both German and English placeholder strings so the same Settings
* values feed into either template language. Missing settings leave the
* placeholder visible so admins notice gaps.
*/
function dsgvo_apply_company_placeholders(string $text): string
{
$companyName = Setting::get('company_name', '');
$street = Setting::get('company_street', '');
$zip = Setting::get('company_zip', '');
$city = Setting::get('company_city', '');
$email = Setting::get('company_email', '');
$dpo = Setting::get('dpo_contact', '');
$dpoEmail = Setting::get('dpo_email', '');
$address = trim("$street, $zip $city", ', ');
$map = [
// German placeholders (default-template.blade.php)
'[Firmenname eintragen]' => $companyName,
'[Adresse eintragen]' => $address,
'[E-Mail-Adresse eintragen]' => $email,
'[DPO-E-Mail-Adresse eintragen]' => $dpoEmail,
'[Datenschutzbeauftragter / Ansprechpartner eintragen]' => $dpo,
// English placeholders (default-template-en.blade.php)
'[Company name]' => $companyName,
'[Address]' => $address,
'[Email]' => $email,
'[DPO email]' => $dpoEmail,
'[Data Protection Officer / Contact]' => $dpo,
];
foreach ($map as $token => $value) {
if ($value !== '') {
$text = str_replace($token, $value, $text);
}
}
return $text;
}

View file

@ -54,21 +54,21 @@ return [
'stack' => [ 'stack' => [
'driver' => 'stack', 'driver' => 'stack',
'channels' => explode(',', (string) env('LOG_STACK', 'daily')), 'channels' => explode(',', (string) env('LOG_STACK', 'single')),
'ignore_exceptions' => false, 'ignore_exceptions' => false,
], ],
'single' => [ 'single' => [
'driver' => 'single', 'driver' => 'single',
'path' => storage_path('logs/laravel.log'), 'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'warning'), 'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true, 'replace_placeholders' => true,
], ],
'daily' => [ 'daily' => [
'driver' => 'daily', 'driver' => 'daily',
'path' => storage_path('logs/laravel.log'), 'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'warning'), 'level' => env('LOG_LEVEL', 'debug'),
'days' => env('LOG_DAILY_DAYS', 14), 'days' => env('LOG_DAILY_DAYS', 14),
'replace_placeholders' => true, 'replace_placeholders' => true,
], ],

1006
schneespur/moduldoku.md Normal file

File diff suppressed because it is too large Load diff

View file

@ -3,8 +3,7 @@
"version": "1.0.0", "version": "1.0.0",
"namespace": "Schneespur\\Module\\Example", "namespace": "Schneespur\\Module\\Example",
"service_provider": "Schneespur\\Module\\Example\\ExampleServiceProvider", "service_provider": "Schneespur\\Module\\Example\\ExampleServiceProvider",
"description": "Reference module demonstrating all extension points (nav, widget, event, settings, route). Dev-only — opt in via EXAMPLE_MODULE_ENABLED=true in .env.", "description": "Reference module demonstrating all extension points (nav, widget, event, settings, route).",
"min_schneespur_version": "1.0.0", "min_schneespur_version": "1.0.0",
"requires_permissions": [], "requires_permissions": []
"default_enabled": false
} }

View file

@ -4,7 +4,6 @@ namespace Schneespur\Module\Example;
use App\Events\JobCompleted; use App\Events\JobCompleted;
use App\Services\Extension\DashboardWidgetRegistry; use App\Services\Extension\DashboardWidgetRegistry;
use App\Services\Extension\FilterRegistry;
use App\Services\Extension\NavigationRegistry; use App\Services\Extension\NavigationRegistry;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
@ -19,28 +18,14 @@ class ExampleServiceProvider extends ServiceProvider
public function boot(): void public function boot(): void
{ {
if (! $this->shouldBoot()) {
return;
}
$this->loadViewsFrom(__DIR__ . '/../resources/views', 'example-module'); $this->loadViewsFrom(__DIR__ . '/../resources/views', 'example-module');
$this->registerNavigation(); $this->registerNavigation();
$this->registerWidget(); $this->registerWidget();
$this->registerFilters();
$this->registerEventListeners(); $this->registerEventListeners();
$this->registerRoutes(); $this->registerRoutes();
} }
/**
* Reference module only loads when explicitly enabled.
* Devs can enable for local exploration via .env: EXAMPLE_MODULE_ENABLED=true
*/
protected function shouldBoot(): bool
{
return (bool) env('EXAMPLE_MODULE_ENABLED', false);
}
protected function registerNavigation(): void protected function registerNavigation(): void
{ {
$nav = $this->app->make(NavigationRegistry::class); $nav = $this->app->make(NavigationRegistry::class);
@ -67,49 +52,6 @@ class ExampleServiceProvider extends ServiceProvider
]); ]);
} }
protected function registerFilters(): void
{
$filters = $this->app->make(FilterRegistry::class);
$filters->register('schneespur.navigation.items', function (array $grouped): array {
$grouped['modules'][] = [
'group' => 'modules',
'slug' => 'example-filter',
'label' => 'Example Filter',
'route' => 'admin.example.settings',
'icon' => 'heroicon-o-funnel',
'order' => 250,
'permission' => null,
'route_check' => null,
'active_pattern' => 'admin.example.settings',
'badge' => null,
];
return $grouped;
}, 150);
$filters->register('schneespur.dashboard.kpis', function (array $widgets): array {
$widgets[] = [
'key' => 'example-filter-widget',
'label' => 'Filter Demo',
'view' => 'example-module::widgets.example-card',
'order' => 250,
'size' => 'half',
];
return $widgets;
}, 150);
$filters->register('schneespur.job.notification.recipients', function (array $recipients, $job): array {
Log::info('ExampleModule: notification recipients filter', [
'job_id' => $job->id ?? null,
'recipient_count' => count($recipients),
]);
return $recipients;
}, 150);
}
protected function registerEventListeners(): void protected function registerEventListeners(): void
{ {
$this->app['events']->listen(JobCompleted::class, function (JobCompleted $event) { $this->app['events']->listen(JobCompleted::class, function (JobCompleted $event) {

File diff suppressed because it is too large Load diff

View file

@ -1,27 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
cacheDirectory=".phpunit.cache"
>
<testsuites>
<testsuite name="Feature">
<directory>tests/Feature</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>app</directory>
</include>
</source>
<php>
<env name="APP_ENV" value="testing"/>
<env name="APP_KEY" value="base64:dGVzdGluZ2tleXRlc3RpbmdrZXl0ZXN0aW5na2V5cw=="/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="MAIL_MAILER" value="array"/>
<env name="CACHE_STORE" value="array"/>
</php>
</phpunit>

File diff suppressed because one or more lines are too long

View file

@ -12,7 +12,7 @@
"src": "node_modules/leaflet/dist/images/marker-icon.png" "src": "node_modules/leaflet/dist/images/marker-icon.png"
}, },
"resources/css/app.css": { "resources/css/app.css": {
"file": "assets/app-Gkl9XGUK.css", "file": "assets/app-DTM5xC6O.css",
"src": "resources/css/app.css", "src": "resources/css/app.css",
"isEntry": true, "isEntry": true,
"name": "app", "name": "app",

View file

@ -1 +1 @@
if(!self.define){let e,s={};const n=(n,r)=>(n=new URL(n+".js",r).href,s[n]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=n,e.onload=s,document.head.appendChild(e)}else e=n,importScripts(n),s()}).then(()=>{let e=s[n];if(!e)throw new Error(`Module ${n} didnt register its module`);return e}));self.define=(r,i)=>{const t=e||("document"in self?document.currentScript.src:"")||location.href;if(s[t])return;let o={};const l=e=>n(e,t),c={module:{uri:t},exports:o,require:l};s[t]=Promise.all(r.map(e=>c[e]||l(e))).then(e=>(i(...e),o))}}define(["./workbox-d7f7d914"],function(e){"use strict";self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"registerSW.js",revision:"2d094791c49e920331981a2d203b8cdb"},{url:"assets/marker-icon-hN30_KVU.png",revision:null},{url:"assets/layers-BWBAp2CZ.png",revision:null},{url:"assets/layers-2x-Bpkbi35X.png",revision:null},{url:"assets/app-Gkl9XGUK.css",revision:null},{url:"assets/app-GNqTWY09.js",revision:null},{url:"manifest.webmanifest",revision:"d9cbc35793758c64f87f24da203c23b4"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(/\.(?:woff2?|ttf|eot|otf)$/,new e.CacheFirst({cacheName:"fonts-cache",plugins:[new e.ExpirationPlugin({maxEntries:30,maxAgeSeconds:31536e3})]}),"GET"),e.registerRoute(/\/driver\/.*/,new e.NetworkFirst({cacheName:"driver-pages-cache",plugins:[new e.ExpirationPlugin({maxEntries:50,maxAgeSeconds:86400})]}),"GET")}); if(!self.define){let e,s={};const n=(n,r)=>(n=new URL(n+".js",r).href,s[n]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=n,e.onload=s,document.head.appendChild(e)}else e=n,importScripts(n),s()}).then(()=>{let e=s[n];if(!e)throw new Error(`Module ${n} didnt register its module`);return e}));self.define=(r,i)=>{const t=e||("document"in self?document.currentScript.src:"")||location.href;if(s[t])return;let o={};const l=e=>n(e,t),c={module:{uri:t},exports:o,require:l};s[t]=Promise.all(r.map(e=>c[e]||l(e))).then(e=>(i(...e),o))}}define(["./workbox-d7f7d914"],function(e){"use strict";self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"registerSW.js",revision:"2d094791c49e920331981a2d203b8cdb"},{url:"assets/marker-icon-hN30_KVU.png",revision:null},{url:"assets/layers-BWBAp2CZ.png",revision:null},{url:"assets/layers-2x-Bpkbi35X.png",revision:null},{url:"assets/app-GNqTWY09.js",revision:null},{url:"assets/app-DTM5xC6O.css",revision:null},{url:"manifest.webmanifest",revision:"d9cbc35793758c64f87f24da203c23b4"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(/\.(?:woff2?|ttf|eot|otf)$/,new e.CacheFirst({cacheName:"fonts-cache",plugins:[new e.ExpirationPlugin({maxEntries:30,maxAgeSeconds:31536e3})]}),"GET"),e.registerRoute(/\/driver\/.*/,new e.NetworkFirst({cacheName:"driver-pages-cache",plugins:[new e.ExpirationPlugin({maxEntries:50,maxAgeSeconds:86400})]}),"GET")});

View file

@ -22,9 +22,9 @@
@endphp @endphp
<form method="POST" action="{{ route('admin.jobs.manual.store') }}" class="mt-6" <form method="POST" action="{{ route('admin.jobs.manual.store') }}" class="mt-6"
x-data="{ x-data="{
selectedCustomerId: {{ old('customer_id') ? (int) old('customer_id') : 'null' }}, selectedCustomerId: {{ old('customer_id', 'null') }},
selectedObjectId: {{ old('customer_object_id') ? (int) old('customer_object_id') : 'null' }}, selectedObjectId: {{ old('customer_object_id', 'null') }},
allObjects: {{ json_encode($allObjects) }}, allObjects: @json($allObjects),
get objects() { return this.allObjects.filter(o => o.customer_id === this.selectedCustomerId); }, get objects() { return this.allObjects.filter(o => o.customer_id === this.selectedCustomerId); },
onCustomerChange() { onCustomerChange() {
const objs = this.objects; const objs = this.objects;

View file

@ -1,74 +1,47 @@
<x-admin-layout> <x-admin-layout>
<x-slot name="header">{{ __('admin.page_settings') }} <x-help-icon topic="settings" /></x-slot> <x-slot name="header">{{ __('admin.page_settings') }} <x-help-icon topic="settings" /></x-slot>
@php
$cards = [
[
'route' => 'admin.settings.branding',
'title' => __('ui.branding_title'),
'desc' => __('ui.branding_description'),
'icon' => 'M9.53 16.122a3 3 0 00-5.78 1.128 2.25 2.25 0 01-2.4 2.245 4.5 4.5 0 008.4-2.245c0-.399-.078-.78-.22-1.128zm0 0a15.998 15.998 0 003.388-1.62m-5.043-.025a15.994 15.994 0 011.622-3.395m3.42 3.42a15.995 15.995 0 004.764-4.648l3.876-5.814a1.151 1.151 0 00-1.597-1.597L14.146 6.32a15.996 15.996 0 00-4.649 4.763m3.42 3.42a6.776 6.776 0 00-3.42-3.42',
],
[
'route' => 'admin.settings.email',
'title' => __('notification.settings_card_email'),
'desc' => __('notification.settings_card_email_desc'),
'icon' => 'M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75',
],
[
'route' => 'admin.settings.notification-log',
'title' => __('notification.settings_card_log'),
'desc' => __('notification.settings_card_log_desc'),
'icon' => 'M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z',
],
[
'route' => 'admin.settings.company',
'title' => __('settings.company_title'),
'desc' => __('settings.company_description'),
'icon' => 'M2.25 21h19.5m-18-18v18m10.5-18v18m6-13.5V21M6.75 6.75h.75m-.75 3h.75m-.75 3h.75m3-6h.75m-.75 3h.75m-.75 3h.75M6.75 21v-3.375c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21M3 3h12m-.75 4.5H21m-3.75 3.75h.008v.008h-.008v-.008zm0 3h.008v.008h-.008v-.008zm0 3h.008v.008h-.008v-.008z',
],
[
'route' => 'admin.settings.retention',
'title' => __('settings.retention_title'),
'desc' => __('settings.retention_description'),
'icon' => 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z',
],
[
'route' => 'admin.settings.weather',
'title' => __('weather.settings_title'),
'desc' => __('weather.settings_description'),
'icon' => 'M2.25 15a4.5 4.5 0 004.5 4.5H18a3.75 3.75 0 001.332-7.257 3 3 0 00-3.758-3.848 5.25 5.25 0 00-10.233 2.33A4.502 4.502 0 002.25 15z',
],
[
'route' => 'admin.settings.update',
'title' => __('update.settings_title'),
'desc' => __('update.settings_description'),
'icon' => 'M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99',
],
[
'route' => 'admin.settings.modules.index',
'title' => __('modules.settings_card_title'),
'desc' => __('modules.settings_card_description'),
'icon' => 'm21 7.5-9-5.25L3 7.5m18 0-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9',
],
];
@endphp
<div class="max-w-2xl space-y-4"> <div class="max-w-2xl space-y-4">
@foreach ($cards as $card) <a href="{{ route('admin.settings.branding') }}" class="block bg-white shadow-sm rounded-lg p-6 hover:bg-gray-50 transition-colors">
<a href="{{ route($card['route']) }}" class="block bg-white shadow-sm rounded-lg p-6 hover:bg-gray-50 transition-colors"> <h3 class="text-sm font-medium text-gray-900">{{ __('ui.branding_title') }}</h3>
<p class="mt-1 text-sm text-gray-500">{{ __('ui.branding_description') }}</p>
</a>
<a href="{{ route('admin.settings.email') }}" class="block bg-white shadow-sm rounded-lg p-6 hover:bg-gray-50 transition-colors">
<h3 class="text-sm font-medium text-gray-900">{{ __('notification.settings_card_email') }}</h3>
<p class="mt-1 text-sm text-gray-500">{{ __('notification.settings_card_email_desc') }}</p>
</a>
<a href="{{ route('admin.settings.notification-log') }}" class="block bg-white shadow-sm rounded-lg p-6 hover:bg-gray-50 transition-colors">
<h3 class="text-sm font-medium text-gray-900">{{ __('notification.settings_card_log') }}</h3>
<p class="mt-1 text-sm text-gray-500">{{ __('notification.settings_card_log_desc') }}</p>
</a>
<a href="{{ route('admin.settings.company') }}" class="block bg-white shadow-sm rounded-lg p-6 hover:bg-gray-50 transition-colors">
<h3 class="text-sm font-medium text-gray-900">{{ __('settings.company_title') }}</h3>
<p class="mt-1 text-sm text-gray-500">{{ __('settings.company_description') }}</p>
</a>
<a href="{{ route('admin.settings.retention') }}" class="block bg-white shadow-sm rounded-lg p-6 hover:bg-gray-50 transition-colors">
<h3 class="text-sm font-medium text-gray-900">{{ __('settings.retention_title') }}</h3>
<p class="mt-1 text-sm text-gray-500">{{ __('settings.retention_description') }}</p>
</a>
<a href="{{ route('admin.settings.weather') }}" class="block bg-white shadow-sm rounded-lg p-6 hover:bg-gray-50 transition-colors">
<h3 class="text-sm font-medium text-gray-900">{{ __('weather.settings_title') }}</h3>
<p class="mt-1 text-sm text-gray-500">{{ __('weather.settings_description') }}</p>
</a>
<a href="{{ route('admin.settings.update') }}" class="block bg-white shadow-sm rounded-lg p-6 hover:bg-gray-50 transition-colors">
<h3 class="text-sm font-medium text-gray-900">{{ __('update.settings_title') }}</h3>
<p class="mt-1 text-sm text-gray-500">{{ __('update.settings_description') }}</p>
</a>
<a href="{{ route('admin.settings.modules.index') }}" class="block bg-white shadow-sm rounded-lg p-6 hover:bg-gray-50 transition-colors">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-indigo-50 text-indigo-600"> <div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-indigo-50 text-indigo-600">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> <svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="{{ $card['icon'] }}" /> <path stroke-linecap="round" stroke-linejoin="round" d="m21 7.5-9-5.25L3 7.5m18 0-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9" />
</svg> </svg>
</div> </div>
<div> <div>
<h3 class="text-sm font-medium text-gray-900">{{ $card['title'] }}</h3> <h3 class="text-sm font-medium text-gray-900">{{ __('modules.settings_card_title') }}</h3>
<p class="mt-1 text-sm text-gray-500">{{ $card['desc'] }}</p> <p class="mt-1 text-sm text-gray-500">{{ __('modules.settings_card_description') }}</p>
</div> </div>
</div> </div>
</a> </a>
@endforeach
</div> </div>
</x-admin-layout> </x-admin-layout>

View file

@ -15,9 +15,9 @@
@endphp @endphp
<form method="POST" action="{{ route('driver.job.manual.store') }}" class="space-y-5" <form method="POST" action="{{ route('driver.job.manual.store') }}" class="space-y-5"
x-data="{ x-data="{
selectedCustomerId: {{ old('customer_id') ? (int) old('customer_id') : 'null' }}, selectedCustomerId: {{ old('customer_id', 'null') }},
selectedObjectId: {{ old('customer_object_id') ? (int) old('customer_object_id') : 'null' }}, selectedObjectId: {{ old('customer_object_id', 'null') }},
allObjects: {{ json_encode($allObjects) }}, allObjects: @json($allObjects),
get objects() { return this.allObjects.filter(o => o.customer_id === this.selectedCustomerId); }, get objects() { return this.allObjects.filter(o => o.customer_id === this.selectedCustomerId); },
onCustomerChange() { onCustomerChange() {
const objs = this.objects; const objs = this.objects;

View file

@ -1,51 +0,0 @@
# Data Protection Information for Winter Maintenance Staff
## 1. Controller
**[Company name]**
[Address]
Email: [Email]
## 2. Purpose of processing
In the course of your work in winter maintenance, the following personal data is collected and processed:
- **GPS location data** during operational hours, to document the gritting and snow-clearance services performed
- **Operational times** (start and end) for working-time records
- **Vehicle assignment** to enable traceability of the vehicles used
- **Photographs and notes** as evidence of the work carried out
## 3. Legal basis
Processing is carried out on the basis of **Article 6(1)(f) UK GDPR / EU GDPR** (legitimate interests). The legitimate interest of the employer lies in fulfilling the duty to maintain safe traffic conditions and in providing evidence that winter maintenance operations have been carried out properly.
## 4. Collection of GPS data
GPS data is collected via the **OwnTracks** app installed on your work device. Location data is recorded only during active operational hours and transmitted to the employer's server. The app does not track you outside of recorded shifts.
## 5. Retention period
The data collected is retained for the duration of statutory retention obligations (typically three years, in line with limitation periods for traffic-safety liability claims). After this period the data is deleted or anonymised.
## 6. Your rights
Under the UK GDPR / EU GDPR you have the following rights:
- **Right of access** (Article 15): you may request information about the personal data we hold about you.
- **Right to rectification** (Article 16): you may request correction of inaccurate data.
- **Right to erasure** (Article 17): you may request deletion of your data, subject to overriding legal retention obligations.
- **Right to restriction of processing** (Article 18)
- **Right to data portability** (Article 20)
- **Right to object** (Article 21)
- **Right to lodge a complaint** with the competent supervisory authority (ICO in the UK; the national DPA in other jurisdictions).
## 7. Contact for data protection enquiries
For any questions regarding data protection please contact:
**[Data Protection Officer / Contact]**
Email: [DPO email]
---
*This is a template review and adapt the wording to your organisation and jurisdiction before publishing to staff.*

View file

@ -1,17 +0,0 @@
<?php
namespace Tests;
use Illuminate\Contracts\Console\Kernel;
trait CreatesApplication
{
public function createApplication(): \Illuminate\Foundation\Application
{
$app = require __DIR__.'/../bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
return $app;
}
}

View file

@ -1,202 +0,0 @@
<?php
namespace Tests\Feature;
use App\Services\Extension\FilterRegistry;
use App\Services\Extension\NavigationRegistry;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Illuminate\Support\Facades\Log;
use Tests\TestCase;
class FilterRegistryTest extends TestCase
{
use LazilyRefreshDatabase;
private FilterRegistry $registry;
protected function setUp(): void
{
parent::setUp();
$this->registry = new FilterRegistry;
}
public function test_apply_returns_original_value_when_no_filters_registered(): void
{
$result = $this->registry->apply('schneespur.nonexistent.hook', ['a', 'b']);
$this->assertSame(['a', 'b'], $result);
}
public function test_filters_execute_in_priority_order(): void
{
$order = [];
$this->registry->register('test.hook', function ($value) use (&$order) {
$order[] = 'priority-200';
return $value;
}, 200);
$this->registry->register('test.hook', function ($value) use (&$order) {
$order[] = 'priority-50';
return $value;
}, 50);
$this->registry->register('test.hook', function ($value) use (&$order) {
$order[] = 'priority-100';
return $value;
}, 100);
$this->registry->apply('test.hook', 'start');
$this->assertSame(['priority-50', 'priority-100', 'priority-200'], $order);
}
public function test_equal_priority_preserves_registration_order(): void
{
$order = [];
$this->registry->register('test.hook', function ($value) use (&$order) {
$order[] = 'first';
return $value;
}, 100);
$this->registry->register('test.hook', function ($value) use (&$order) {
$order[] = 'second';
return $value;
}, 100);
$this->registry->register('test.hook', function ($value) use (&$order) {
$order[] = 'third';
return $value;
}, 100);
$this->registry->apply('test.hook', 'start');
$this->assertSame(['first', 'second', 'third'], $order);
}
public function test_apply_passes_context_to_callbacks(): void
{
$receivedContext = [];
$this->registry->register('test.hook', function ($value, $ctxA, $ctxB) use (&$receivedContext) {
$receivedContext = [$ctxA, $ctxB];
return $value;
});
$this->registry->apply('test.hook', 'value', 'context-a', 'context-b');
$this->assertSame(['context-a', 'context-b'], $receivedContext);
}
public function test_throwing_callback_logs_warning_and_preserves_previous_value(): void
{
Log::shouldReceive('warning')
->once()
->withArgs(function ($message, $context) {
return $message === 'FilterRegistry: callback failed'
&& $context['hook'] === 'test.hook'
&& str_contains($context['error'], 'Intentional test exception');
});
$this->registry->register('test.hook', function (array $value) {
$value[] = 'before-error';
return $value;
}, 10);
$this->registry->register('test.hook', function ($value) {
throw new \RuntimeException('Intentional test exception');
}, 20);
$this->registry->register('test.hook', function (array $value) {
$value[] = 'after-error';
return $value;
}, 30);
$result = $this->registry->apply('test.hook', []);
$this->assertSame(['before-error', 'after-error'], $result);
}
public function test_callbacks_can_transform_value_through_chain(): void
{
$this->registry->register('test.hook', function (int $value) {
return $value + 10;
}, 10);
$this->registry->register('test.hook', function (int $value) {
return $value * 2;
}, 20);
$result = $this->registry->apply('test.hook', 5);
$this->assertSame(30, $result);
}
public function test_single_callback_executes(): void
{
$this->registry->register('test.hook', function (array $items) {
$items[] = 'added';
return $items;
});
$result = $this->registry->apply('test.hook', ['original']);
$this->assertSame(['original', 'added'], $result);
}
public function test_navigation_items_hook_is_applied(): void
{
$this->bootExampleModule();
$nav = $this->app->make(NavigationRegistry::class);
$nav->addGroup('modules', 'Modules', 100);
$items = $nav->getItems();
$allSlugs = [];
foreach ($items as $groupItems) {
foreach ($groupItems as $item) {
$allSlugs[] = $item['slug'];
}
}
$this->assertContains('example-filter', $allSlugs);
}
public function test_dashboard_kpis_hook_is_applied(): void
{
$this->bootExampleModule();
$filterRegistry = $this->app->make(FilterRegistry::class);
$widgets = $filterRegistry->apply('schneespur.dashboard.kpis', [
['key' => 'original-widget', 'label' => 'Original'],
]);
$keys = array_column($widgets, 'key');
$this->assertContains('example-filter-widget', $keys);
}
private function bootExampleModule(): void
{
$modulePath = base_path('modules/example/src');
spl_autoload_register(function (string $class) use ($modulePath) {
$prefix = 'Schneespur\\Module\\Example\\';
if (! str_starts_with($class, $prefix)) {
return;
}
$relative = substr($class, strlen($prefix));
$file = $modulePath . '/' . str_replace('\\', '/', $relative) . '.php';
if (file_exists($file)) {
require_once $file;
}
});
putenv('EXAMPLE_MODULE_ENABLED=true');
$_ENV['EXAMPLE_MODULE_ENABLED'] = true;
$this->app->register(\Schneespur\Module\Example\ExampleServiceProvider::class);
}
}

View file

@ -1,14 +0,0 @@
<?php
namespace Tests;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
protected function setUp(): void
{
parent::setUp();
$this->withoutVite();
}
}