Compare commits

...

8 commits
v1.0.3 ... main

Author SHA1 Message Date
Michael
a00d7ef1c9 chore(repo): slim .gitignore and remove dev artifacts from tracking
Cleaning up the public-facing repo:
- /build.sh, /moduldoku.md, /package-lock.json (root) untracked in
  prior commit and now blocked via .gitignore
- .gitignore itself: dropped GSD baseline noise, deduped overlapping
  patterns, added the three newly-untracked dev files

The schneespur/ subdirectory (Laravel app) is what users consume;
root-level dev tooling has no business in the distributed source.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 13:06:15 +00:00
Michael
382ee67868 test: Bootstrapped PHPUnit test infrastructure and wrote 9 FilterRegist…
- schneespur/phpunit.xml
- schneespur/tests/TestCase.php
- schneespur/tests/CreatesApplication.php
- schneespur/tests/Feature/FilterRegistryTest.php

GSD context:
- Milestone: M013 - Hook-Fundament — FilterRegistry, Events, NotificationChannelRegistry, Modul-Plumbing
- Slice: S01
- Task: T04 - Bootstrapped PHPUnit test infrastructure and wrote 9 FilterRegistry Feature tests covering priority ordering, stable sort, error isolation, context passing, value chaining, and two integration tests proving navigation and dashboard hooks are wired

GSD-Task: S01/T04
2026-05-21 13:04:10 +00:00
Michael
182f1b4c98 Release v1.0.5: Bugfix-Welle (Manual-Job-Form, :app_name, Logging, DSGVO-EN, Icons)
Sammelrelease nach Feedback aus der Test-Installation. Sieben unabhängige
Probleme — vom Form-Breaker über Übersetzungs-Lücken bis Log-Hygiene.

- fix(jobs): Einsatz manuell erfassen brach das HTML-Attribut, sobald
  Customer-Namen ein " enthielten (@json in x-data=""). Auf json_encode
  via {{ }} umgestellt (HTML-escaped). Alpine initialisiert wieder,
  Anlegen-Button funktioniert.
- feat(i18n): BrandedTranslator injiziert :app_name automatisch in alle
  __()-Aufrufe. Hilfetexte, Mails, Update-Strings rendern jetzt überall
  korrekt 'Schneespur' bzw. 'Wintertrace' statt rohes :app_name.
- feat(dsgvo): EN-Default-Vorlage default-template-en.blade.php mit
  UK/EU GDPR-Wording (Art. 6(1)(f), ICO-Hinweis, Subject Rights).
  Controller laden locale-aware mit Fallback auf DE. Placeholder-Helper
  kennt DE + EN Tokens.
- ui(settings): Alle 8 Settings-Karten haben jetzt Icons, nicht nur
  Module. Markup auf array-driven Loop entrümpelt.
- chore(modules): Example-Modul boot()-gated via EXAMPLE_MODULE_ENABLED
  env-Var (default false). Aus Release-ZIP komplett entfernt. Bestehende
  Installs mit altem example/-Ordner laden es nicht mehr automatisch.
- chore(logging): ModuleManager Discovery/Boot-Logs auf debug
  runtergesetzt (waren info → fluteten laravel.log bei jedem Request).
  Defaults auf daily-Rotation mit 14d Retention + LOG_LEVEL=warning für
  Production.
- fix(install): Hardcoded deutsche Fehlermeldung im InstallerController
  durch __('install.preflight_has_failures') ersetzt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 12:55:19 +00:00
Michael
ed375efb22 chore: auto-commit after execute-task
GSD-Unit: M013/S01/T03
2026-05-20 14:46:51 +00:00
Michael
ea727985ea chore: auto-commit after execute-task
GSD-Unit: M013/S01/T02
2026-05-20 14:44:33 +00:00
Michael
41878e92ef chore: auto-commit after execute-task
GSD-Unit: M013/S01/T01
2026-05-20 14:42:18 +00:00
Michael
87a84eb0ac Release v1.0.4: self-updater hotfixes (wrapper stripping + counter check)
- Self-updater extractZip: detect and strip a common top-level prefix
  folder so that release ZIPs built with a versioned wrapper (the
  build.sh convention) overlay the live install correctly instead of
  creating a schneespur-X.Y.Z/ subdirectory at the install root.
  The v1.0.3 update on the test environment surfaced this — files were
  copied, state.json was committed to 1.0.3, but the wrapper folder
  meant the live config/app.php and VERSION were never actually
  overwritten. Same defensive prefix-stripping pattern as the module
  installer.

- Counter check ordering: the same-version short-circuit now runs
  BEFORE the strict counter check. Previously, after a successful
  install, the next "prüfen" fetched the same manifest (same counter)
  and the counter check would throw "Rollback-Versuch", masking the
  intended "you're up to date" response. The counter check is still
  enforced for genuinely older manifests — it just doesn't misfire
  when the server keeps serving the same release.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 14:03:12 +00:00
Michael
84c68ee35c Move moduldoku.md to project root
This is an internal authoring doc, not part of the shipped Laravel
application. Lifting it out of the schneespur/ subdirectory keeps it
out of the release build (build.sh copies only the Laravel app
directories) and clarifies the separation between code-that-ships
and meta documentation about the module system.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 13:20:33 +00:00
31 changed files with 8015 additions and 1139 deletions

50
.gitignore vendored
View file

@ -1,40 +1,44 @@
# Dependencies # ── Dependencies ──
/schneespur/vendor/
/node_modules/ /node_modules/
/vendor/
/schneespur/node_modules/
/schneespur/vendor/
# Environment # ── Local env (keep .env.example tracked) ──
.env .env
.env.backup .env.*
.env.production !.env.example
# IDE / Editor # ── Laravel runtime ──
.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/
# Testing # ── Internal dev files (not part of distributable app) ──
/build.sh
/moduldoku.md
/package-lock.json
# ── Test caches ──
.phpunit.result.cache .phpunit.result.cache
.phpunit.cache/ .phpunit.cache/
# Release builds # ── IDE / OS ──
/release/ .idea/
.vscode/
*.swp
.DS_Store
Thumbs.db
# Misc # ── Local tooling ──
*.bak .gsd
*.orig .gsd-id
.mcp.json
.bg-shell/

View file

@ -18,9 +18,10 @@ APP_MAINTENANCE_DRIVER=file
BCRYPT_ROUNDS=12 BCRYPT_ROUNDS=12
LOG_CHANNEL=stack LOG_CHANNEL=stack
LOG_STACK=single LOG_STACK=daily
LOG_DEPRECATIONS_CHANNEL=null LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug LOG_LEVEL=warning
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.3 1.0.5

View file

@ -5,6 +5,7 @@ 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;
@ -17,9 +18,10 @@ class DashboardController extends Controller
return redirect()->route('admin.dashboard'); return redirect()->route('admin.dashboard');
} }
public function index(DashboardWidgetRegistry $widgetRegistry): View public function index(DashboardWidgetRegistry $widgetRegistry, FilterRegistry $filterRegistry): 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,6 +3,7 @@
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;
@ -18,7 +19,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('dsgvo.default-template')->render(); $markdown = view(DsgvoOnboardingController::resolveDefaultTemplateView())->render();
} }
$previewHtml = Str::markdown($this->replacePlaceholders($markdown), ['html_input' => 'strip']); $previewHtml = Str::markdown($this->replacePlaceholders($markdown), ['html_input' => 'strip']);
@ -82,25 +83,7 @@ class DsgvoAdminController extends Controller
private function replacePlaceholders(string $text): string private function replacePlaceholders(string $text): string
{ {
$companyName = Setting::get('company_name', ''); return dsgvo_apply_company_placeholders($text);
$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,32 +66,22 @@ 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('dsgvo.default-template')->render(); $text = view(self::resolveDefaultTemplateView())->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
{ {
$companyName = Setting::get('company_name', ''); return dsgvo_apply_company_placeholders($text);
$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' => 'Kritische Voraussetzungen nicht erfüllt.']); ->withErrors(['preflight' => __('install.preflight_has_failures')]);
} }
return redirect()->route('install.database'); return redirect()->route('install.database');

View file

@ -4,6 +4,7 @@ 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;
@ -15,6 +16,7 @@ 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
@ -35,6 +37,7 @@ 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,12 +15,14 @@ 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;
@ -46,6 +48,7 @@ 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));
@ -64,6 +67,17 @@ 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

@ -0,0 +1,43 @@
<?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,6 +8,10 @@ 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];
@ -73,6 +77,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 $grouped; return $this->filterRegistry->apply('schneespur.navigation.items', $grouped);
} }
} }

View file

@ -56,7 +56,7 @@ class ModuleManager
$this->modules[$slug] = $manifest; $this->modules[$slug] = $manifest;
Log::info('ModuleManager: module discovered', [ Log::debug('ModuleManager: module discovered', [
'slug' => $slug, 'slug' => $slug,
'version' => $manifest['version'] ?? 'unknown', 'version' => $manifest['version'] ?? 'unknown',
]); ]);
@ -93,6 +93,13 @@ 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';
@ -128,7 +135,7 @@ class ModuleManager
$provider->register(); $provider->register();
$provider->boot(); $provider->boot();
Log::info('ModuleManager: module booted', [ Log::debug('ModuleManager: module booted', [
'slug' => $slug, 'slug' => $slug,
'version' => $manifest['version'] ?? 'unknown', 'version' => $manifest['version'] ?? 'unknown',
]); ]);
@ -165,7 +172,7 @@ class ModuleManager
$this->disabledModules[] = $slug; $this->disabledModules[] = $slug;
} }
Log::info('ModuleManager: module disabled', ['slug' => $slug]); Log::debug('ModuleManager: module disabled', ['slug' => $slug]);
return true; return true;
} }

View file

@ -170,6 +170,18 @@ 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(
@ -177,12 +189,6 @@ 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;
@ -279,7 +285,44 @@ class SchneespurUpdater
$this->validateZipEntries($zip); $this->validateZipEntries($zip);
$zip->extractTo($this->stagingDir); $prefix = $this->detectCommonPrefix($zip);
if ($prefix === null) {
$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)]);
@ -287,6 +330,46 @@ 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

@ -0,0 +1,26 @@
<?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,3 +23,46 @@ 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', 'single')), 'channels' => explode(',', (string) env('LOG_STACK', 'daily')),
'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', 'debug'), 'level' => env('LOG_LEVEL', 'warning'),
'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', 'debug'), 'level' => env('LOG_LEVEL', 'warning'),
'days' => env('LOG_DAILY_DAYS', 14), 'days' => env('LOG_DAILY_DAYS', 14),
'replace_placeholders' => true, 'replace_placeholders' => true,
], ],

File diff suppressed because it is too large Load diff

View file

@ -3,7 +3,8 @@
"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).", "description": "Reference module demonstrating all extension points (nav, widget, event, settings, route). Dev-only — opt in via EXAMPLE_MODULE_ENABLED=true in .env.",
"min_schneespur_version": "1.0.0", "min_schneespur_version": "1.0.0",
"requires_permissions": [] "requires_permissions": [],
"default_enabled": false
} }

View file

@ -4,6 +4,7 @@ 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;
@ -18,14 +19,28 @@ 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);
@ -52,6 +67,49 @@ 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) {

7283
schneespur/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

27
schneespur/phpunit.xml Normal file
View file

@ -0,0 +1,27 @@
<?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-DTM5xC6O.css", "file": "assets/app-Gkl9XGUK.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-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")}); 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")});

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', 'null') }}, selectedCustomerId: {{ old('customer_id') ? (int) old('customer_id') : 'null' }},
selectedObjectId: {{ old('customer_object_id', 'null') }}, selectedObjectId: {{ old('customer_object_id') ? (int) old('customer_object_id') : 'null' }},
allObjects: @json($allObjects), allObjects: {{ json_encode($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,47 +1,74 @@
<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">
<a href="{{ route('admin.settings.branding') }}" class="block bg-white shadow-sm rounded-lg p-6 hover:bg-gray-50 transition-colors"> @foreach ($cards as $card)
<h3 class="text-sm font-medium text-gray-900">{{ __('ui.branding_title') }}</h3> <a href="{{ route($card['route']) }}" class="block bg-white shadow-sm rounded-lg p-6 hover:bg-gray-50 transition-colors">
<p class="mt-1 text-sm text-gray-500">{{ __('ui.branding_description') }}</p> <div class="flex items-center gap-3">
</a> <div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-indigo-50 text-indigo-600">
<a href="{{ route('admin.settings.email') }}" class="block bg-white shadow-sm rounded-lg p-6 hover:bg-gray-50 transition-colors"> <svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<h3 class="text-sm font-medium text-gray-900">{{ __('notification.settings_card_email') }}</h3> <path stroke-linecap="round" stroke-linejoin="round" d="{{ $card['icon'] }}" />
<p class="mt-1 text-sm text-gray-500">{{ __('notification.settings_card_email_desc') }}</p> </svg>
</a> </div>
<a href="{{ route('admin.settings.notification-log') }}" class="block bg-white shadow-sm rounded-lg p-6 hover:bg-gray-50 transition-colors"> <div>
<h3 class="text-sm font-medium text-gray-900">{{ __('notification.settings_card_log') }}</h3> <h3 class="text-sm font-medium text-gray-900">{{ $card['title'] }}</h3>
<p class="mt-1 text-sm text-gray-500">{{ __('notification.settings_card_log_desc') }}</p> <p class="mt-1 text-sm text-gray-500">{{ $card['desc'] }}</p>
</a> </div>
<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 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">
<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>
</div> </div>
<div> </a>
<h3 class="text-sm font-medium text-gray-900">{{ __('modules.settings_card_title') }}</h3> @endforeach
<p class="mt-1 text-sm text-gray-500">{{ __('modules.settings_card_description') }}</p>
</div>
</div>
</a>
</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', 'null') }}, selectedCustomerId: {{ old('customer_id') ? (int) old('customer_id') : 'null' }},
selectedObjectId: {{ old('customer_object_id', 'null') }}, selectedObjectId: {{ old('customer_object_id') ? (int) old('customer_object_id') : 'null' }},
allObjects: @json($allObjects), allObjects: {{ json_encode($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

@ -0,0 +1,51 @@
# 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

@ -0,0 +1,17 @@
<?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

@ -0,0 +1,202 @@
<?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

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