Release v1.0.2: diagnostic infrastructure core

Add neutral diagnostic framework for future reporting modules:
- DiagnosticReporterInterface, Registry, Manager, PayloadSanitizer
- Laravel exception hook in bootstrap/app.php
- Module permission declarations (requires_permissions in module.json)
- Core diagnostic report points (module boot/install/update failures)
- Module documentation update (moduldoku.md)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michael 2026-05-18 16:54:11 +00:00
parent 2c63440ed8
commit 7288b93500
8107 changed files with 1085684 additions and 9 deletions

View file

@ -1 +1 @@
1.0.1
1.0.2

View file

@ -4,6 +4,7 @@ namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Module;
use App\Services\ModuleManager;
use App\Services\SchneespurModuleClient;
use App\Services\SchneespurModuleInstaller;
use Illuminate\Http\RedirectResponse;
@ -58,6 +59,7 @@ class AdminModuleController extends Controller
'download_url' => $catModule['download_url'] ?? null,
'sha256' => $catModule['sha256'] ?? null,
'size_bytes' => $catModule['size_bytes'] ?? null,
'requires_permissions' => $catModule['requires_permissions'] ?? [],
];
}
@ -80,6 +82,7 @@ class AdminModuleController extends Controller
'download_url' => null,
'sha256' => null,
'size_bytes' => null,
'requires_permissions' => $this->resolveLocalPermissions($slug),
];
}
@ -230,6 +233,16 @@ class AdminModuleController extends Controller
->with('success', __('modules.disabled', ['slug' => $slug]));
}
private function resolveLocalPermissions(string $slug): array
{
try {
$manager = app(ModuleManager::class);
return $manager->getPermissions($slug);
} catch (\Throwable) {
return [];
}
}
public function remove(string $slug, SchneespurModuleInstaller $installer): RedirectResponse
{
$module = Module::where('slug', $slug)->first();

View file

@ -11,6 +11,9 @@ use App\Models\User;
use App\Models\Vehicle;
use App\Policies\JobPolicy;
use App\Services\AlertService;
use App\Services\Diagnostic\DiagnosticManager;
use App\Services\Diagnostic\DiagnosticPayloadSanitizer;
use App\Services\Diagnostic\DiagnosticReporterRegistry;
use App\Services\Extension\DashboardWidgetRegistry;
use App\Services\Extension\NavigationRegistry;
use App\Services\ForecastService;
@ -44,6 +47,9 @@ class AppServiceProvider extends ServiceProvider
$this->app->singleton(AlertService::class);
$this->app->singleton(DashboardWidgetRegistry::class);
$this->app->singleton(NavigationRegistry::class);
$this->app->singleton(DiagnosticPayloadSanitizer::class);
$this->app->singleton(DiagnosticReporterRegistry::class, fn ($app) => new DiagnosticReporterRegistry($app));
$this->app->singleton(DiagnosticManager::class);
$this->app->singleton(ModuleManager::class, fn ($app) => new ModuleManager($app));
$this->app->singleton(WeatherProviderRegistry::class, function ($app) {
$registry = new WeatherProviderRegistry($app);

View file

@ -0,0 +1,83 @@
<?php
namespace App\Services\Diagnostic;
use Illuminate\Support\Facades\Log;
class DiagnosticManager
{
private bool $dispatching = false;
public function __construct(
private readonly DiagnosticReporterRegistry $registry,
private readonly DiagnosticPayloadSanitizer $sanitizer,
) {}
public function report(string $type, array $payload = [], array $context = []): void
{
if ($this->dispatching) {
return;
}
$this->dispatching = true;
try {
$sanitizedPayload = $this->sanitizer->sanitize($payload);
$baseContext = $this->sanitizer->buildContext();
$mergedContext = array_merge($baseContext, $this->sanitizer->sanitize($context));
foreach ($this->registry->enabledReporters() as $slug => $reporter) {
try {
$reporter->report($type, $sanitizedPayload, $mergedContext);
} catch (\Throwable $e) {
Log::warning('DiagnosticManager: reporter failed', [
'reporter' => $slug,
'type' => $type,
'error' => $e->getMessage(),
]);
}
}
} finally {
$this->dispatching = false;
}
}
public function reportException(\Throwable $e, array $context = [], bool $includeTrace = true): void
{
$payload = $this->sanitizer->sanitizeException($e, $includeTrace);
$this->report('exception', $payload, $context);
}
public function hasReporters(): bool
{
return count($this->registry->all()) > 0;
}
public function hasEnabledReporters(): bool
{
return count($this->registry->enabledReporters()) > 0;
}
/**
* @return array<string, array{ok: bool, message: string, latency_ms: int}>
*/
public function testAllConnections(): array
{
$results = [];
foreach ($this->registry->enabledReporters() as $slug => $reporter) {
try {
$results[$slug] = $reporter->testConnection();
} catch (\Throwable $e) {
$results[$slug] = [
'ok' => false,
'message' => $e->getMessage(),
'latency_ms' => 0,
];
}
}
return $results;
}
}

View file

@ -0,0 +1,210 @@
<?php
namespace App\Services\Diagnostic;
class DiagnosticPayloadSanitizer
{
private const REDACTED = '[REDACTED]';
private const SENSITIVE_KEYS = [
'password',
'password_confirmation',
'passwort',
'secret',
'token',
'api_key',
'apikey',
'api-key',
'authorization',
'auth',
'cookie',
'cookies',
'session',
'session_id',
'csrf',
'_token',
'credit_card',
'card_number',
'cvv',
'ssn',
'dsn',
];
private const EMAIL_PATTERN = '/[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}/';
private const IPV4_PATTERN = '/\b(?:\d{1,3}\.){3}\d{1,3}\b/';
public function sanitize(array $payload): array
{
return $this->walkArray($payload);
}
public function sanitizeException(\Throwable $e, bool $includeTrace = false): array
{
$sanitized = [
'class' => get_class($e),
'message' => $this->truncateMessage($e->getMessage()),
'code' => $e->getCode(),
'file' => $this->stripBasePath($e->getFile()),
'line' => $e->getLine(),
];
if ($includeTrace) {
$sanitized['trace'] = $this->sanitizeTrace($e);
}
return $sanitized;
}
public function buildContext(): array
{
$context = [
'schneespur_version' => $this->readVersion(),
'php_version' => PHP_VERSION,
'laravel_version' => app()->version(),
'environment' => app()->environment(),
'active_modules' => $this->activeModuleSlugs(),
];
if (app()->runningInConsole()) {
$context['channel'] = 'cli';
} else {
$context['channel'] = 'http';
$context['route'] = $this->currentRouteWithoutQuery();
$context['method'] = request()->method();
}
return $context;
}
private function walkArray(array $data, int $depth = 0): array
{
if ($depth > 10) {
return [self::REDACTED];
}
$result = [];
foreach ($data as $key => $value) {
$lowerKey = strtolower((string) $key);
if ($this->isSensitiveKey($lowerKey)) {
$result[$key] = self::REDACTED;
continue;
}
if (is_array($value)) {
$result[$key] = $this->walkArray($value, $depth + 1);
} elseif (is_string($value)) {
$result[$key] = $this->sanitizeString($value, $lowerKey);
} else {
$result[$key] = $value;
}
}
return $result;
}
private function isSensitiveKey(string $key): bool
{
foreach (self::SENSITIVE_KEYS as $sensitive) {
if ($key === $sensitive || str_contains($key, $sensitive)) {
return true;
}
}
return false;
}
private function sanitizeString(string $value, string $key): string
{
if (in_array($key, ['email', 'e-mail', 'mail', 'e_mail'], true)) {
return self::REDACTED;
}
$value = preg_replace(self::EMAIL_PATTERN, self::REDACTED, $value);
$value = preg_replace(self::IPV4_PATTERN, self::REDACTED, $value);
return $value;
}
private function truncateMessage(string $message, int $maxLength = 500): string
{
$message = preg_replace(self::EMAIL_PATTERN, self::REDACTED, $message);
$message = preg_replace(self::IPV4_PATTERN, self::REDACTED, $message);
if (mb_strlen($message) > $maxLength) {
return mb_substr($message, 0, $maxLength) . '...';
}
return $message;
}
private function sanitizeTrace(\Throwable $e): array
{
$frames = [];
foreach (array_slice($e->getTrace(), 0, 30) as $frame) {
$frames[] = [
'file' => isset($frame['file']) ? $this->stripBasePath($frame['file']) : null,
'line' => $frame['line'] ?? null,
'function' => ($frame['class'] ?? '') . ($frame['type'] ?? '') . ($frame['function'] ?? ''),
];
}
return $frames;
}
private function stripBasePath(string $path): string
{
$base = base_path() . '/';
return str_starts_with($path, $base)
? substr($path, strlen($base))
: $path;
}
private function currentRouteWithoutQuery(): ?string
{
try {
$route = request()->route();
if ($route) {
return $route->uri();
}
return parse_url(request()->url(), PHP_URL_PATH);
} catch (\Throwable) {
return null;
}
}
private function readVersion(): string
{
try {
$path = base_path('VERSION');
if (file_exists($path)) {
return trim(file_get_contents($path));
}
} catch (\Throwable) {
}
return 'unknown';
}
private function activeModuleSlugs(): array
{
try {
$manager = app(\App\Services\ModuleManager::class);
$slugs = [];
foreach ($manager->getAll() as $slug => $manifest) {
if ($manager->isEnabled($slug)) {
$slugs[] = $slug;
}
}
return $slugs;
} catch (\Throwable) {
return [];
}
}
}

View file

@ -0,0 +1,22 @@
<?php
namespace App\Services\Diagnostic;
interface DiagnosticReporterInterface
{
/**
* Report a diagnostic event to this reporter.
*
* @param string $type Event type, e.g. 'exception', 'cron_failed', 'module_boot_failed'
* @param array $payload Sanitized event data
* @param array $context Additional context (route, user role, schneespur version, etc.)
*/
public function report(string $type, array $payload = [], array $context = []): void;
public function isEnabled(): bool;
/**
* @return array{ok: bool, message: string, latency_ms: int}
*/
public function testConnection(): array;
}

View file

@ -0,0 +1,51 @@
<?php
namespace App\Services\Diagnostic;
use App\Services\Extension\ExtensionRegistry;
use Illuminate\Contracts\Container\Container;
class DiagnosticReporterRegistry extends ExtensionRegistry
{
public function __construct(
private readonly Container $container,
) {}
/**
* @param class-string<DiagnosticReporterInterface> $class
*/
public function register(string $slug, mixed $class): void
{
parent::register($slug, $class);
}
public function resolve(?string $slug = null): ?DiagnosticReporterInterface
{
if ($slug === null || ! $this->has($slug)) {
return null;
}
return $this->container->make($this->items[$slug]);
}
/**
* @return array<string, DiagnosticReporterInterface>
*/
public function enabledReporters(): array
{
$enabled = [];
foreach ($this->all() as $slug => $class) {
try {
$reporter = $this->container->make($class);
if ($reporter->isEnabled()) {
$enabled[$slug] = $reporter;
}
} catch (\Throwable) {
// Skip reporters that fail to instantiate
}
}
return $enabled;
}
}

View file

@ -2,6 +2,7 @@
namespace App\Services;
use App\Services\Diagnostic\DiagnosticManager;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\ServiceProvider;
@ -138,6 +139,7 @@ class ModuleManager
'trace' => $e->getTraceAsString(),
]);
$this->autoDisable($slug, $e->getMessage());
$this->reportDiagnostic('module_boot_failed', $slug, $e);
}
}
}
@ -188,6 +190,13 @@ class ModuleManager
return $this->disabledModules;
}
public function getPermissions(string $slug): array
{
$manifest = $this->getManifest($slug);
return $manifest['requires_permissions'] ?? [];
}
protected function autoDisable(string $slug, string $reason): void
{
if (! in_array($slug, $this->disabledModules, true)) {
@ -199,4 +208,20 @@ class ModuleManager
'reason' => $reason,
]);
}
private function reportDiagnostic(string $type, string $slug, \Throwable $e): void
{
try {
$manager = app(DiagnosticManager::class);
$manager->report($type, [
'module_slug' => $slug,
'class' => get_class($e),
'message' => $e->getMessage(),
'file' => $e->getFile(),
'line' => $e->getLine(),
]);
} catch (\Throwable) {
// Never let diagnostic reporting interfere with module management
}
}
}

View file

@ -2,6 +2,7 @@
namespace App\Services;
use App\Services\Diagnostic\DiagnosticManager;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
use RuntimeException;
@ -28,7 +29,13 @@ class SchneespurModuleInstaller
return false;
}
return $this->extractZip($zipPath, $targetDir, $slug);
$result = $this->extractZip($zipPath, $targetDir, $slug);
if (! $result) {
$this->reportDiagnostic('module_install_failed', $slug, 'ZIP extraction failed');
}
return $result;
}
public function update(string $zipPath, string $slug): bool
@ -56,6 +63,7 @@ class SchneespurModuleInstaller
}
Log::error('schneespur-modules: update failed, triggering rollback', ['slug' => $slug]);
$this->reportDiagnostic('module_update_failed', $slug, 'ZIP extraction failed, rollback triggered');
$this->rollback($slug);
return false;
}
@ -161,4 +169,15 @@ class SchneespurModuleInstaller
{
return $this->modulesPath . '/' . $slug . '.bak';
}
private function reportDiagnostic(string $type, string $slug, string $reason): void
{
try {
app(DiagnosticManager::class)->report($type, [
'module_slug' => $slug,
'reason' => $reason,
]);
} catch (\Throwable) {
}
}
}

View file

@ -8,6 +8,7 @@ use App\Http\Middleware\EnsureDsgvoInformed;
use App\Http\Middleware\InstallerGuard;
use App\Http\Middleware\RedirectToInstaller;
use App\Http\Middleware\SetInstallerLocale;
use App\Services\Diagnostic\DiagnosticManager;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Console\Scheduling\Schedule;
@ -71,5 +72,16 @@ return Application::configure(basePath: dirname(__DIR__))
$schedule->call(fn () => cache()->put('cron.last_run', now()))->everyMinute();
})
->withExceptions(function (Exceptions $exceptions): void {
//
$exceptions->reportable(function (\Throwable $e) {
try {
$manager = app(DiagnosticManager::class);
if ($manager->hasEnabledReporters()) {
$manager->reportException($e);
}
} catch (\Throwable) {
// Never let diagnostic reporting break the application
}
return false;
});
})->create();

View file

@ -45,4 +45,6 @@ return [
'update_failed' => 'Aktualisierung von ":slug" fehlgeschlagen: :error',
'directory_exists' => 'Modulverzeichnis existiert bereits.',
'extraction_failed' => 'ZIP-Entpacken fehlgeschlagen.',
'permission_tooltip' => 'Dieses Modul benötigt diese Berechtigung.',
];

View file

@ -45,4 +45,6 @@ return [
'update_failed' => 'Update of ":slug" failed: :error',
'directory_exists' => 'Module directory already exists.',
'extraction_failed' => 'ZIP extraction failed.',
'permission_tooltip' => 'This module requires this permission.',
];

1006
moduldoku.md Normal file

File diff suppressed because it is too large Load diff

View file

@ -4,5 +4,6 @@
"namespace": "Schneespur\\Module\\Example",
"service_provider": "Schneespur\\Module\\Example\\ExampleServiceProvider",
"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": []
}

File diff suppressed because one or more lines are too long

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"
},
"resources/css/app.css": {
"file": "assets/app-frBbeKiu.css",
"file": "assets/app-DTM5xC6O.css",
"src": "resources/css/app.css",
"isEntry": true,
"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-frBbeKiu.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")});

Binary file not shown.

View file

@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[compose.yaml]
indent_size = 4

View file

@ -0,0 +1,54 @@
APP_NAME=Schneespur
APP_ENV=production
APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost
APP_LOCALE=de
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=de_DE
APP_TIMEZONE=UTC
APP_DISPLAY_TIMEZONE=Europe/Berlin
APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database
# PHP_CLI_SERVER_WORKERS=4
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=schneespur
DB_USERNAME=root
DB_PASSWORD=
SESSION_DRIVER=file
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
CACHE_STORE=file
# CACHE_PREFIX=
MAIL_MAILER=log
MAIL_SCHEME=null
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
VITE_APP_NAME="${APP_NAME}"

View file

@ -0,0 +1,295 @@
# Schneespur — Installationsanleitung
Diese Anleitung beschreibt die Installation von Schneespur auf einem klassischen Shared-Webhosting (Strato, IONOS, All-Inkl o. ä.) mit PHP und MySQL. SSH oder Docker sind **nicht** erforderlich.
---
## Inhaltsverzeichnis
1. [Systemvoraussetzungen](#1-systemvoraussetzungen)
2. [Dateien hochladen](#2-dateien-hochladen)
3. [Document-Root konfigurieren](#3-document-root-konfigurieren)
4. [Datenbank anlegen](#4-datenbank-anlegen)
5. [Installations-Assistent](#5-installations-assistent)
6. [Cron-Job einrichten](#6-cron-job-einrichten)
7. [OwnTracks einrichten](#7-owntracks-einrichten)
8. [Update-Anleitung](#8-update-anleitung)
9. [Backup](#9-backup)
10. [Troubleshooting](#10-troubleshooting)
---
## 1. Systemvoraussetzungen
| Anforderung | Minimum | Empfohlen |
|-------------|---------|-----------|
| PHP | 8.2 | 8.3 oder 8.4 |
| MySQL | 5.7 | 8.0+ |
| MariaDB (alternativ) | 10.3 | 10.6+ |
### Benötigte PHP-Erweiterungen
**Pflicht** (Installation schlägt ohne diese fehl):
- `pdo_mysql`
- `gd`
**Empfohlen** (Warnungen im Assistenten, wenn fehlend):
- `mbstring`
- `openssl`
- `tokenizer`
- `xml`
- `ctype`
- `json`
- `bcmath`
- `fileinfo`
> Die meisten Shared-Hosting-Anbieter haben alle genannten Erweiterungen bereits aktiviert.
### Weitere Voraussetzungen
- FTP- oder Dateimanager-Zugang zum Webspace
- Eine MySQL/MariaDB-Datenbank (wird vom Hoster bereitgestellt)
- Das Document-Root muss auf einen Unterordner (`/public`) zeigbar sein
---
## 2. Dateien hochladen
1. Laden Sie das aktuelle Schneespur-Release herunter (ZIP-Archiv).
2. Entpacken Sie das Archiv auf Ihrem Computer.
3. Laden Sie den gesamten Inhalt per FTP oder Dateimanager in Ihr Webverzeichnis hoch, z. B. `/schneespur/` oder direkt ins Hauptverzeichnis.
**Ordnerstruktur nach dem Upload:**
```
/schneespur/
app/
bootstrap/
config/
database/
lang/
public/ <-- hierhin muss das Document-Root zeigen
resources/
routes/
storage/
vendor/
.env.example
artisan
composer.json
...
```
---
## 3. Document-Root konfigurieren
Das Document-Root (manchmal auch „Webroot" oder „Stammverzeichnis" genannt) Ihrer Domain muss auf den Unterordner `public/` zeigen.
**Beispiel:** Wenn Sie die Dateien nach `/schneespur/` hochgeladen haben, setzen Sie das Document-Root auf `/schneespur/public/`.
So geht das bei gängigen Hostern:
- **Strato:** Paket-Verwaltung → Domain-Verwaltung → Umleitung/Ziel → Pfad ändern
- **IONOS:** Hosting → Domains → Document-Root bearbeiten
- **All-Inkl:** Domain-Einstellungen → Ordnerzuordnung
> **Wichtig:** Wenn Ihr Hoster keinen Unterordner als Document-Root erlaubt, verschieben Sie den Inhalt von `public/` ins Hauptverzeichnis und passen Sie die Pfade in `index.php` entsprechend an. Der Installations-Assistent hilft dabei nicht — kontaktieren Sie im Zweifel Ihren Hoster.
---
## 4. Datenbank anlegen
Erstellen Sie über das Verwaltungspanel Ihres Hosters eine neue MySQL-Datenbank. Notieren Sie sich:
- **Host** (z. B. `localhost` oder `rdbms.strato.de`)
- **Port** (Standard: `3306`)
- **Datenbankname**
- **Benutzername**
- **Passwort**
Diese Daten benötigen Sie im nächsten Schritt.
---
## 5. Installations-Assistent
Öffnen Sie Ihre Domain im Browser. Schneespur erkennt automatisch, dass noch keine Installation vorliegt, und startet den Assistenten.
### Schritt 1: Willkommen
Der Assistent prüft die Grundvoraussetzungen und erzeugt die Konfigurationsdatei (`.env`) sowie den Anwendungsschlüssel (`APP_KEY`).
### Schritt 2: Datenbank
Geben Sie die Zugangsdaten aus Schritt 4 ein. Der Assistent testet die Verbindung, bevor er fortfährt.
> Falls die `.env`-Datei nicht beschreibbar ist (selten bei Shared-Hosting), zeigt der Assistent eine Anleitung zum manuellen Bearbeiten per FTP an.
### Schritt 3: Systemcheck
Der Assistent prüft PHP-Version, Erweiterungen und Schreibrechte auf wichtige Verzeichnisse (`storage/`, `bootstrap/cache/`). Fehlende Erweiterungen werden als Pflicht oder Empfehlung markiert.
### Schritt 4: Datenbank-Migration
Die Datenbanktabellen werden automatisch angelegt. Dieser Schritt kann bei Fehlern beliebig oft wiederholt werden, ohne Datenverlust.
### Schritt 5: Anwendungskonfiguration
Legen Sie fest:
- **App-URL** (Ihre Domain, z. B. `https://schneespur.meinefirma.de`)
- **Zeitzone** (z. B. `Europe/Berlin`)
- **Sprache** (`de` oder `en`)
### Schritt 6: Speicher & Caches
Der Assistent erstellt die Verknüpfung zum öffentlichen Speicher (`storage:link`) und baut Caches auf. Falls die Verknüpfung auf Ihrem Hoster nicht funktioniert, wird eine Anleitung zum manuellen Anlegen per FTP angezeigt.
### Schritt 7: Admin-Konto
Erstellen Sie Ihr Administrator-Konto (Name, E-Mail, Passwort mit mindestens 8 Zeichen).
### Schritt 8: E-Mail-Konfiguration (optional)
Richten Sie SMTP-Versand ein, damit Schneespur Benachrichtigungen senden kann. Dieser Schritt kann übersprungen und später in den Einstellungen nachgeholt werden.
### Fertig
Nach Abschluss sehen Sie eine Zusammenfassung. Sie können sich jetzt mit Ihren Admin-Zugangsdaten anmelden.
---
## 6. Cron-Job einrichten
Schneespur benötigt einen Cron-Job, der einmal pro Minute den Laravel-Scheduler ausführt. Dieser verarbeitet die Auftragswarteschlange (z. B. Wetterdaten abrufen, Benachrichtigungen senden).
### Cron-Befehl
```
* * * * * /usr/local/bin/php /pfad/zu/schneespur/artisan schedule:run >> /dev/null 2>&1
```
> **Wichtig:** Ersetzen Sie `/pfad/zu/schneespur/` durch den tatsächlichen Pfad auf Ihrem Webspace und `/usr/local/bin/php` durch den PHP-Pfad Ihres Hosters (häufig auch `/usr/bin/php` oder `/usr/bin/php8.3`).
### So richten Sie den Cron-Job ein
- **Strato:** Paket-Verwaltung → Cron-Jobs → Neuer Cronjob
- **IONOS:** Hosting → Cron-Jobs → Cronjob anlegen
- **All-Inkl:** Tools → Cronjobs → Neuer Cronjob
Stellen Sie die Ausführung auf **jede Minute** oder das kürzeste verfügbare Intervall.
### Warum ist der Cron-Job nötig?
Ohne Cron-Job werden keine Hintergrundaufgaben verarbeitet:
- Wetterdaten werden nicht automatisch zu Einsätzen hinzugefügt
- E-Mail-Benachrichtigungen werden nicht versendet
- Geplante Aufgaben laufen nicht
---
## 7. OwnTracks einrichten
OwnTracks ist die GPS-Tracking-App, mit der Ihre Fahrer die Einsätze aufzeichnen. Jeder Fahrer benötigt die App auf seinem Smartphone.
### Kurzanleitung
1. **App installieren:** OwnTracks aus dem App Store (iOS) oder Google Play Store (Android) herunterladen.
2. **Zugangsdaten erzeugen:** Melden Sie sich als Admin in Schneespur an, öffnen Sie die Fahrer-Übersicht und klicken Sie beim jeweiligen Fahrer auf „Zugangsdaten". Schneespur erzeugt automatisch Benutzername und Passwort.
3. **QR-Code scannen:** Auf der Zugangsdaten-Seite wird ein QR-Code angezeigt. Der Fahrer scannt diesen mit der OwnTracks-App, und die Verbindung wird automatisch konfiguriert.
4. **Manuell konfigurieren** (falls QR-Code nicht funktioniert):
- Modus: **HTTP**
- URL: `https://ihre-domain.de/api/owntracks/report`
- Benutzername und Passwort: wie in Schneespur angezeigt
5. **Testen:** Öffnen Sie in Schneespur unter „OwnTracks" die Übersicht. Sobald der Fahrer die App startet, sollte dort ein grüner Status erscheinen.
---
## 8. Update-Anleitung
### Vor dem Update
1. Erstellen Sie ein Backup (siehe [Backup](#9-backup)).
2. Aktivieren Sie den Wartungsmodus: Öffnen Sie `https://ihre-domain.de/down` im Browser oder führen Sie `php artisan down` per SSH/Cron aus.
### Update durchführen
1. Laden Sie das neue Release herunter.
2. Überschreiben Sie alle Dateien per FTP. Überspringen Sie dabei **nicht** die `.env`-Datei — diese wird beim Upload ohnehin nicht überschrieben, solange Sie nur die Release-Dateien hochladen.
3. Führen Sie die Datenbank-Migration aus. Dafür gibt es zwei Wege:
- **Ueber den Browser:** Öffnen Sie `https://ihre-domain.de/admin/settings` und prüfen Sie, ob eine Update-Migration angeboten wird.
- **Per Cron/SSH:** `php artisan migrate --force`
4. Leeren Sie die Caches: `php artisan config:cache && php artisan view:cache`
5. Deaktivieren Sie den Wartungsmodus: Öffnen Sie `https://ihre-domain.de/up` oder führen Sie `php artisan up` aus.
---
## 9. Backup
### Was sichern?
| Was | Wo | Wie |
|-----|----|----|
| Datenbank | MySQL-Datenbank | phpMyAdmin → Export (SQL-Format) |
| Hochgeladene Dateien | `storage/app/` | Per FTP herunterladen |
| Konfiguration | `.env`-Datei im Hauptverzeichnis | Per FTP herunterladen |
### Empfohlener Rhythmus
- **Datenbank:** wöchentlich oder vor jedem Update
- **Dateien:** vor jedem Update
- **Konfiguration:** nach jeder Änderung und vor Updates
---
## 10. Troubleshooting
### Installations-Assistent erscheint nicht
- Prüfen Sie, ob das Document-Root korrekt auf `/public` zeigt.
- Prüfen Sie, ob die `.htaccess`-Datei im `public/`-Ordner vorhanden ist.
- Stellen Sie sicher, dass `mod_rewrite` (Apache) aktiviert ist.
### Datenbankverbindung schlägt fehl
- Prüfen Sie Host, Port, Datenbankname, Benutzername und Passwort.
- Bei Strato lautet der Host oft `rdbms.strato.de`, nicht `localhost`.
- Stellen Sie sicher, dass der Datenbankbenutzer Zugriff auf die angegebene Datenbank hat.
### Seite zeigt „500 Internal Server Error"
- Prüfen Sie die Schreibrechte: `storage/` und `bootstrap/cache/` müssen beschreibbar sein (Rechte 755 oder 775).
- Schauen Sie in `storage/logs/laravel.log` nach der Fehlermeldung.
### GPS-Daten kommen nicht an
- Prüfen Sie in OwnTracks, ob der Modus auf „HTTP" steht (nicht MQTT).
- Prüfen Sie die URL: `https://ihre-domain.de/api/owntracks/report`
- Prüfen Sie Benutzername und Passwort in der OwnTracks-App.
- Öffnen Sie die OwnTracks-Übersicht in Schneespur — dort wird der letzte Verbindungsstatus angezeigt.
### Wetterdaten fehlen bei Einsätzen
- Stellen Sie sicher, dass der Cron-Job läuft (siehe [Cron-Job einrichten](#6-cron-job-einrichten)).
- Wetterdaten werden über Open-Meteo abgerufen. Prüfen Sie, ob Ihr Server ausgehende HTTPS-Verbindungen erlaubt.
### E-Mails werden nicht versendet
- Prüfen Sie die SMTP-Einstellungen unter Einstellungen → E-Mail.
- Nutzen Sie die Test-E-Mail-Funktion in den Einstellungen.
- Schauen Sie in `storage/logs/laravel.log` nach Fehlermeldungen.
### Cron-Job funktioniert nicht
- Prüfen Sie den PHP-Pfad: Führen Sie `which php` aus oder fragen Sie Ihren Hoster.
- Prüfen Sie den Pfad zur `artisan`-Datei.
- Testen Sie den Befehl manuell: `php /pfad/zu/schneespur/artisan schedule:run`
---
## Hilfe
Bei Fragen nutzen Sie die integrierte Hilfe im Admin-Bereich (Menü → Hilfe) oder erstellen Sie ein Issue im GitHub-Repository.

View file

@ -0,0 +1,295 @@
# Schneespur — Installation Guide
This guide describes how to install Schneespur on a standard shared web hosting plan (Strato, IONOS, All-Inkl, or similar) with PHP and MySQL. SSH and Docker are **not** required.
---
## Table of Contents
1. [System Requirements](#1-system-requirements)
2. [Upload Files](#2-upload-files)
3. [Configure Document Root](#3-configure-document-root)
4. [Create Database](#4-create-database)
5. [Installation Wizard](#5-installation-wizard)
6. [Set Up Cron Job](#6-set-up-cron-job)
7. [Set Up OwnTracks](#7-set-up-owntracks)
8. [Update Instructions](#8-update-instructions)
9. [Backup](#9-backup)
10. [Troubleshooting](#10-troubleshooting)
---
## 1. System Requirements
| Requirement | Minimum | Recommended |
|-------------|---------|-------------|
| PHP | 8.2 | 8.3 or 8.4 |
| MySQL | 5.7 | 8.0+ |
| MariaDB (alternative) | 10.3 | 10.6+ |
### Required PHP Extensions
**Mandatory** (installation will fail without these):
- `pdo_mysql`
- `gd`
**Recommended** (warnings in the wizard if missing):
- `mbstring`
- `openssl`
- `tokenizer`
- `xml`
- `ctype`
- `json`
- `bcmath`
- `fileinfo`
> Most shared hosting providers have all of the above extensions enabled by default.
### Additional Requirements
- FTP or file manager access to your web space
- A MySQL/MariaDB database (provided by your hosting plan)
- The document root must be configurable to point to a subdirectory (`/public`)
---
## 2. Upload Files
1. Download the latest Schneespur release (ZIP archive).
2. Extract the archive on your computer.
3. Upload the entire contents via FTP or your hosting provider's file manager to your web directory, e.g. `/schneespur/` or directly into the root directory.
**Folder structure after upload:**
```
/schneespur/
app/
bootstrap/
config/
database/
lang/
public/ <-- document root must point here
resources/
routes/
storage/
vendor/
.env.example
artisan
composer.json
...
```
---
## 3. Configure Document Root
Your domain's document root (sometimes called "web root" or "home directory") must point to the `public/` subdirectory.
**Example:** If you uploaded the files to `/schneespur/`, set the document root to `/schneespur/public/`.
How to do this on common hosts:
- **Strato:** Package management → Domain management → Redirect/Target → Change path
- **IONOS:** Hosting → Domains → Edit document root
- **All-Inkl:** Domain settings → Folder assignment
> **Important:** If your host does not allow setting a subdirectory as the document root, move the contents of `public/` into the main directory and adjust the paths in `index.php` accordingly. The installation wizard does not help with this — contact your host's support if unsure.
---
## 4. Create Database
Create a new MySQL database through your hosting provider's control panel. Make note of:
- **Host** (e.g. `localhost` or `rdbms.strato.de`)
- **Port** (default: `3306`)
- **Database name**
- **Username**
- **Password**
You will need these in the next step.
---
## 5. Installation Wizard
Open your domain in a browser. Schneespur automatically detects that no installation exists and starts the wizard.
### Step 1: Welcome
The wizard checks basic requirements and creates the configuration file (`.env`) along with the application key (`APP_KEY`).
### Step 2: Database
Enter the database credentials from Step 4. The wizard tests the connection before proceeding.
> If the `.env` file is not writable (rare on shared hosting), the wizard displays instructions for manual editing via FTP.
### Step 3: System Check
The wizard verifies the PHP version, extensions, and write permissions on key directories (`storage/`, `bootstrap/cache/`). Missing extensions are flagged as mandatory or recommended.
### Step 4: Database Migration
Database tables are created automatically. This step can be retried as many times as needed without data loss.
### Step 5: Application Configuration
Configure the following:
- **App URL** (your domain, e.g. `https://schneespur.mycompany.com`)
- **Timezone** (e.g. `Europe/Berlin`)
- **Language** (`de` or `en`)
### Step 6: Storage & Caches
The wizard creates the public storage symlink (`storage:link`) and builds caches. If the symlink fails on your host, instructions for manual FTP setup are displayed.
### Step 7: Admin Account
Create your administrator account (name, email, password with at least 8 characters).
### Step 8: Email Configuration (optional)
Set up SMTP so Schneespur can send notifications. This step can be skipped and completed later in the settings.
### Done
After completion you will see a summary. You can now log in with your admin credentials.
---
## 6. Set Up Cron Job
Schneespur requires a cron job that runs the Laravel scheduler once per minute. This processes the job queue (e.g. fetching weather data, sending notifications).
### Cron Command
```
* * * * * /usr/local/bin/php /path/to/schneespur/artisan schedule:run >> /dev/null 2>&1
```
> **Important:** Replace `/path/to/schneespur/` with the actual path on your web space and `/usr/local/bin/php` with your host's PHP path (often `/usr/bin/php` or `/usr/bin/php8.3`).
### How to Set Up the Cron Job
- **Strato:** Package management → Cron jobs → New cron job
- **IONOS:** Hosting → Cron jobs → Create cron job
- **All-Inkl:** Tools → Cron jobs → New cron job
Set the execution interval to **every minute** or the shortest interval available.
### Why Is the Cron Job Needed?
Without the cron job, no background tasks are processed:
- Weather data is not automatically added to jobs
- Email notifications are not sent
- Scheduled tasks do not run
---
## 7. Set Up OwnTracks
OwnTracks is the GPS tracking app your drivers use to record their operations. Each driver needs the app on their smartphone.
### Quick Start
1. **Install the app:** Download OwnTracks from the App Store (iOS) or Google Play Store (Android).
2. **Generate credentials:** Log in to Schneespur as admin, open the driver list, and click "Credentials" for the respective driver. Schneespur automatically generates a username and password.
3. **Scan QR code:** The credentials page displays a QR code. The driver scans it with the OwnTracks app, and the connection is configured automatically.
4. **Manual configuration** (if the QR code does not work):
- Mode: **HTTP**
- URL: `https://your-domain.com/api/owntracks/report`
- Username and password: as shown in Schneespur
5. **Test:** Open the OwnTracks overview in Schneespur. Once the driver starts the app, a green status indicator should appear.
---
## 8. Update Instructions
### Before Updating
1. Create a backup (see [Backup](#9-backup)).
2. Enable maintenance mode: Open `https://your-domain.com/down` in a browser, or run `php artisan down` via SSH/cron.
### Perform the Update
1. Download the new release.
2. Overwrite all files via FTP. Do **not** skip the `.env` file — it will not be overwritten as long as you only upload the release files.
3. Run the database migration. There are two ways:
- **Via browser:** Open `https://your-domain.com/admin/settings` and check if an update migration is offered.
- **Via cron/SSH:** `php artisan migrate --force`
4. Clear caches: `php artisan config:cache && php artisan view:cache`
5. Disable maintenance mode: Open `https://your-domain.com/up` or run `php artisan up`.
---
## 9. Backup
### What to Back Up
| What | Where | How |
|------|-------|----|
| Database | MySQL database | phpMyAdmin → Export (SQL format) |
| Uploaded files | `storage/app/` | Download via FTP |
| Configuration | `.env` file in the root directory | Download via FTP |
### Recommended Schedule
- **Database:** weekly or before each update
- **Files:** before each update
- **Configuration:** after each change and before updates
---
## 10. Troubleshooting
### Installation Wizard Does Not Appear
- Verify that the document root points to `/public`.
- Check that the `.htaccess` file exists in the `public/` directory.
- Make sure `mod_rewrite` (Apache) is enabled.
### Database Connection Fails
- Double-check the host, port, database name, username, and password.
- On Strato, the host is often `rdbms.strato.de`, not `localhost`.
- Ensure the database user has access to the specified database.
### Page Shows "500 Internal Server Error"
- Check write permissions: `storage/` and `bootstrap/cache/` must be writable (permissions 755 or 775).
- Check `storage/logs/laravel.log` for the error message.
### GPS Data Is Not Arriving
- In OwnTracks, make sure the mode is set to "HTTP" (not MQTT).
- Verify the URL: `https://your-domain.com/api/owntracks/report`
- Check the username and password in the OwnTracks app.
- Open the OwnTracks overview in Schneespur — it shows the last connection status.
### Weather Data Is Missing from Jobs
- Make sure the cron job is running (see [Set Up Cron Job](#6-set-up-cron-job)).
- Weather data is fetched from Open-Meteo. Check that your server allows outgoing HTTPS connections.
### Emails Are Not Being Sent
- Check the SMTP settings under Settings → Email.
- Use the test email function in the settings.
- Check `storage/logs/laravel.log` for error messages.
### Cron Job Is Not Working
- Verify the PHP path: run `which php` or ask your hosting provider.
- Verify the path to the `artisan` file.
- Test the command manually: `php /path/to/schneespur/artisan schedule:run`
---
## Help
For questions, use the built-in help in the admin area (Menu → Help) or open an issue in the GitHub repository.

View file

@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.

View file

@ -0,0 +1,138 @@
<p align="center">
<img src="schneespur/public/pwa-icon-512x512.png" alt="Schneespur" width="120">
</p>
<h1 align="center">Schneespur</h1>
<p align="center">
Quelloffene, selbst gehostete Winterdienst-Dokumentation.<br>
GPS-Tracks &middot; Wetterdaten &middot; Fotos &middot; rechtsfester Einsatznachweis
</p>
<p align="center">
<a href="https://schneespur.de">schneespur.de</a> &middot;
<a href="https://wintertrace.com">wintertrace.com</a>
</p>
<p align="center">
<a href="#english">English</a> &middot;
<a href="INSTALL.de.md">Installation (DE)</a> &middot;
<a href="INSTALL.en.md">Installation (EN)</a> &middot;
<a href="https://schneespur.de/download/">Download</a>
</p>
---
## Was ist Schneespur?
Schneespur dokumentiert Räum- und Streueinsätze für kleine Winterdienst-Betriebe — vollständig, automatisch und rechtssicher. Die Software läuft auf jedem günstigen Shared-Webhosting (Strato, IONOS, All-Inkl, ...) und braucht weder SSH noch Docker.
**Kernversprechen:** Wenn ein Passant auf einer gestreuten Fläche ausrutscht und der Betreiber nachweisen muss, dass er seiner Verkehrssicherungspflicht nachgekommen ist, liefert Schneespur den Beleg — mit GPS-Track, Wetterlage, Fotos und Zeitstempeln.
### Funktionen
- **GPS-Tracking** via [OwnTracks](https://owntracks.org)-App (iOS/Android) — kein eigener Tracking-Client nötig
- **Automatische Wetterdokumentation** — Temperatur, Niederschlag, Wind, Schneelage zum Einsatzzeitpunkt (Open-Meteo, BrightSky, Met.no)
- **Foto-Dokumentation** — Bilder direkt aus der Fahrer-App hochladen
- **PDF-Einsatznachweise** — einzeln oder als Sammelreport pro Kunde und Zeitraum
- **Kundenportal** — Kunden können ihre Einsätze selbst einsehen
- **Fahrer-App (PWA)** — funktioniert offline, synchronisiert automatisch bei Verbindung
- **Kunden- und Objektverwaltung** — mehrere Objekte pro Kunde, Zuordnung zu Einsätzen
- **Fahrzeugverwaltung** — Fuhrpark mit Kennzeichen und Fahrzeugtyp
- **DSGVO-konform** — Fahrer-Anonymisierung, Datenexport, konfigurierbare Aufbewahrungsfristen
- **Automatische Updates** — kryptographisch signiert (Ed25519), ein Klick im Admin-Panel
- **Modulsystem** — erweiterbar über Module aus dem Schneespur-Modulkatalog
### Systemanforderungen
| Komponente | Minimum |
|------------|---------|
| PHP | 8.2 |
| MySQL | 5.7 / MariaDB 10.3 |
| Webserver | Apache mit `mod_rewrite` |
| PHP-Extensions | `pdo_mysql`, `mbstring`, `openssl`, `gd`, `sodium`, `fileinfo` |
| Speicherplatz | ca. 50 MB + Fotos |
### Schnellstart
1. [Download](https://schneespur.de/download/) der aktuellen Version (ZIP)
2. ZIP entpacken und per FTP auf den Webserver laden
3. Document Root auf den `public/`-Ordner setzen
4. Im Browser die Domain aufrufen — der Installations-Assistent führt durch die Einrichtung
Detaillierte Anleitung: **[INSTALL.de.md](INSTALL.de.md)**
### Tech-Stack
| Bereich | Technologie |
|---------|-------------|
| Backend | PHP 8.2+ / Laravel 12 |
| Frontend | Blade + Alpine.js + Tailwind CSS v4 |
| Karten | Leaflet + OpenStreetMap |
| PDF | DomPDF (rein PHP, kein Chrome/Puppeteer) |
| PWA | Workbox via vite-plugin-pwa |
| Wetter | Open-Meteo / BrightSky / Met.no |
### Lizenz
Schneespur ist lizenziert unter der [GNU Affero General Public License v3.0](LICENSE).
---
<h2 id="english">English</h2>
> The international edition of this software is called **Wintertrace**. The branding is set during installation based on the chosen language.
### What is Schneespur?
Schneespur (German) / Wintertrace (international) is an open-source, self-hosted winter service documentation platform for small snow removal and gritting operators. It runs on any standard shared web hosting (no SSH or Docker required).
**Core promise:** When a pedestrian slips on a cleared surface and the operator needs to prove they fulfilled their duty of care, Schneespur provides the evidence — GPS track, weather conditions, photos, and timestamps.
### Features
- **GPS tracking** via [OwnTracks](https://owntracks.org) app (iOS/Android) — no custom tracking client needed
- **Automatic weather documentation** — temperature, precipitation, wind, snow depth at the time of service (Open-Meteo, BrightSky, Met.no)
- **Photo documentation** — upload images directly from the driver app
- **PDF proof-of-service reports** — individual or batch reports per customer and time period
- **Customer portal** — customers can review their service records
- **Driver app (PWA)** — works offline, syncs automatically when connected
- **Customer & site management** — multiple sites per customer, assigned to jobs
- **Vehicle management** — fleet with license plates and vehicle types
- **GDPR-compliant** — driver anonymization, data export, configurable retention periods
- **Automatic updates** — cryptographically signed (Ed25519), one click in the admin panel
- **Module system** — extensible via modules from the Schneespur module catalog
### System Requirements
| Component | Minimum |
|-----------|---------|
| PHP | 8.2 |
| MySQL | 5.7 / MariaDB 10.3 |
| Web server | Apache with `mod_rewrite` |
| PHP extensions | `pdo_mysql`, `mbstring`, `openssl`, `gd`, `sodium`, `fileinfo` |
| Disk space | approx. 50 MB + photos |
### Quick Start
1. [Download](https://wintertrace.com/download/) the latest release (ZIP)
2. Extract and upload via FTP to your web server
3. Set the document root to the `public/` directory
4. Open the domain in your browser — the installation wizard guides you through setup
Detailed guide: **[INSTALL.en.md](INSTALL.en.md)**
### Tech Stack
| Area | Technology |
|------|------------|
| Backend | PHP 8.2+ / Laravel 12 |
| Frontend | Blade + Alpine.js + Tailwind CSS v4 |
| Maps | Leaflet + OpenStreetMap |
| PDF | DomPDF (pure PHP, no Chrome/Puppeteer) |
| PWA | Workbox via vite-plugin-pwa |
| Weather | Open-Meteo / BrightSky / Met.no |
### License
Schneespur is licensed under the [GNU Affero General Public License v3.0](LICENSE).

View file

@ -0,0 +1 @@
1.0.2

View file

@ -0,0 +1,52 @@
<?php
namespace App\Console\Commands;
use App\Models\Module;
use App\Services\SchneespurModuleClient;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Schema;
class ModulesList extends Command
{
protected $signature = 'schneespur:modules-list';
protected $description = 'List all installed modules with their state.';
public function handle(SchneespurModuleClient $client): int
{
if (! Schema::hasTable('modules')) {
$this->info('Keine Module installiert.');
return 0;
}
$modules = Module::orderBy('slug')->get();
if ($modules->isEmpty()) {
$this->info('Keine Module installiert.');
return 0;
}
$rows = $modules->map(fn (Module $m) => [
$m->slug,
$m->version ?? '—',
$m->enabled ? '✓' : '✗',
$m->installed_at?->format('Y-m-d H:i') ?? '—',
])->toArray();
$this->table(['Slug', 'Version', 'Aktiv', 'Installiert am'], $rows);
$state = $client->loadState();
$orphans = $state['orphans'] ?? [];
if (! empty($orphans)) {
$this->newLine();
$this->warn('Verwaiste Module (nicht mehr im Katalog):');
foreach ($orphans as $slug) {
$this->warn("{$slug}");
}
}
return 0;
}
}

View file

@ -0,0 +1,63 @@
<?php
namespace App\Console\Commands;
use App\Models\Module;
use App\Services\SchneespurModuleClient;
use App\Services\SchneespurModuleInstaller;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Schema;
class ModulesRemove extends Command
{
protected $signature = 'schneespur:modules-remove
{slug : The module slug to remove}
{--force : Skip confirmation prompt}';
protected $description = 'Remove an installed module completely.';
public function handle(
SchneespurModuleInstaller $installer,
SchneespurModuleClient $client,
): int {
if (! Schema::hasTable('modules')) {
$this->error('Modules-Tabelle nicht vorhanden. Bitte zuerst "php artisan migrate" ausführen.');
return 1;
}
$slug = $this->argument('slug');
$module = Module::bySlug($slug)->first();
if (! $module) {
$this->error("Modul \"{$slug}\" nicht gefunden.");
return 1;
}
if (! $this->option('force')) {
if (! $this->confirm("Modul \"{$slug}\" (v{$module->version}) wirklich entfernen?")) {
$this->info('Abgebrochen.');
return 0;
}
}
$module->update(['enabled' => false]);
$removed = $installer->remove($slug);
if (! $removed) {
$this->warn("Modul-Dateien für \"{$slug}\" konnten nicht gelöscht werden (evtl. bereits entfernt).");
}
$module->delete();
$state = $client->loadState();
$state['installed'] = Module::pluck('slug')->toArray();
$state['orphans'] = array_values(array_diff($state['orphans'] ?? [], [$slug]));
$client->writeState($state);
$this->info("Modul \"{$slug}\" wurde entfernt.");
return 0;
}
}

View file

@ -0,0 +1,184 @@
<?php
namespace App\Console\Commands;
use App\Models\Module;
use App\Services\SchneespurModuleClient;
use App\Services\SchneespurModuleInstaller;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Schema;
class ModulesSync extends Command
{
protected $signature = 'schneespur:modules-sync
{--dry-run : Show what would happen without making changes}';
protected $description = 'Sync modules from the catalog server (install/update/skip).';
public function handle(
SchneespurModuleClient $client,
SchneespurModuleInstaller $installer,
): int {
if (! Schema::hasTable('modules')) {
$this->error('Modules-Tabelle nicht vorhanden. Bitte zuerst "php artisan migrate" ausführen.');
return 1;
}
$dryRun = $this->option('dry-run');
$appVersion = config('app.version', '0.0.0');
if ($dryRun) {
$this->info('[DRY-RUN] Keine Änderungen werden vorgenommen.');
}
$this->info('Katalog wird abgerufen…');
try {
$catalog = $client->fetchCatalog();
} catch (\Throwable $e) {
$this->error('Katalog-Fetch fehlgeschlagen: ' . $e->getMessage());
return 1;
}
if ($catalog === null) {
$this->info('Katalog nicht geändert (304). Nichts zu tun.');
return 0;
}
$modules = $catalog['modules'] ?? [];
$catalogSlugs = [];
$installed = 0;
$updated = 0;
$skipped = 0;
foreach ($modules as $entry) {
$slug = $entry['slug'] ?? null;
if (! $slug) {
continue;
}
$catalogSlugs[] = $slug;
$version = $entry['version'] ?? 'unknown';
$sha256 = $entry['sha256'] ?? null;
$size = $entry['size'] ?? null;
$downloadUrl = $entry['download_url'] ?? null;
$minAppVersion = $entry['minimum_app_version'] ?? null;
if ($minAppVersion && version_compare($appVersion, $minAppVersion, '<')) {
$this->warn("Modul {$slug} benötigt Schneespur >= {$minAppVersion}, aktuell {$appVersion} — übersprungen.");
$skipped++;
continue;
}
if (! $sha256 || ! $downloadUrl || ! $size) {
$this->warn("Modul {$slug}: Fehlende Metadaten (sha256/download_url/size) — übersprungen.");
$skipped++;
continue;
}
$existing = Module::bySlug($slug)->first();
if ($existing) {
$existingManifest = $existing->manifest_json ?? [];
$existingSha = $existingManifest['sha256'] ?? null;
if ($existingSha === $sha256) {
$this->line(" {$slug} v{$version} — aktuell, übersprungen.");
$skipped++;
continue;
}
if ($dryRun) {
$this->info("[DRY-RUN] Würde aktualisieren: {$slug} → v{$version}");
$updated++;
continue;
}
$this->info("Aktualisiere {$slug} → v{$version}");
try {
$zipPath = $client->downloadModule($slug, $downloadUrl, $sha256, $size);
$success = $installer->update($zipPath, $slug);
@unlink($zipPath);
if (! $success) {
$this->error("Update fehlgeschlagen für {$slug}.");
continue;
}
$existing->update([
'version' => $version,
'manifest_json' => $entry,
]);
$updated++;
$this->info("{$slug} aktualisiert auf v{$version}.");
} catch (\Throwable $e) {
$this->error("Fehler bei {$slug}: " . $e->getMessage());
}
} else {
if ($dryRun) {
$this->info("[DRY-RUN] Würde installieren: {$slug} v{$version}");
$installed++;
continue;
}
$this->info("Installiere {$slug} v{$version}");
try {
$zipPath = $client->downloadModule($slug, $downloadUrl, $sha256, $size);
$success = $installer->install($zipPath, $slug);
@unlink($zipPath);
if (! $success) {
$this->error("Installation fehlgeschlagen für {$slug}.");
continue;
}
Module::create([
'slug' => $slug,
'version' => $version,
'enabled' => true,
'manifest_json' => $entry,
'installed_at' => now(),
]);
$installed++;
$this->info("{$slug} v{$version} installiert.");
} catch (\Throwable $e) {
$this->error("Fehler bei {$slug}: " . $e->getMessage());
}
}
}
$this->detectOrphans($catalogSlugs, $client);
$this->newLine();
$this->info("Sync abgeschlossen: {$installed} installiert, {$updated} aktualisiert, {$skipped} übersprungen.");
if (! $dryRun) {
$state = $client->loadState();
$state['installed'] = Module::pluck('slug')->toArray();
$client->writeState($state);
}
return 0;
}
private function detectOrphans(array $catalogSlugs, SchneespurModuleClient $client): void
{
$localSlugs = Module::pluck('slug')->toArray();
$orphans = array_diff($localSlugs, $catalogSlugs);
if (empty($orphans)) {
return;
}
$this->warn('Verwaiste Module (lokal installiert, nicht mehr im Katalog):');
foreach ($orphans as $slug) {
$this->warn("{$slug}");
}
$state = $client->loadState();
$state['orphans'] = array_values($orphans);
$client->writeState($state);
}
}

View file

@ -0,0 +1,61 @@
<?php
namespace App\Console\Commands;
use App\Models\Setting;
use App\Services\RetentionService;
use Illuminate\Console\Command;
class PurgeExpiredJobs extends Command
{
protected $signature = 'jobs:retention-delete
{--dry-run : Show what would be deleted without deleting}
{--limit=50 : Maximum jobs to delete per run}';
protected $description = 'Delete expired jobs after the configured retention period, preserving monthly aggregates.';
public function handle(RetentionService $retentionService): int
{
$isDryRun = $this->option('dry-run');
$limit = (int) $this->option('limit');
if (! $isDryRun && ! Setting::get('retention_auto_delete', false)) {
$this->info('Auto-Löschung ist deaktiviert.');
return 0;
}
$jobs = $retentionService->getExpiredJobs($limit);
if ($jobs->isEmpty()) {
$this->info('Keine abgelaufenen Einsätze.');
return 0;
}
if ($isDryRun) {
$this->info("Folgende Einsätze würden gelöscht ({$jobs->count()}):");
$this->table(
['ID', 'Kunde', 'Beendet am'],
$jobs->map(fn ($job) => [
$job->id,
$job->customer?->name ?? '',
$job->ended_at->format('d.m.Y H:i'),
]),
);
return 0;
}
$deleted = $retentionService->purge($limit);
$this->info("{$deleted} Einsätze gelöscht und in Monatsstatistik aggregiert.");
if ($deleted < $jobs->count()) {
$failed = $jobs->count() - $deleted;
$this->warn("{$failed} Einsätze konnten nicht gelöscht werden. Siehe Laravel-Log.");
}
return 0;
}
}

View file

@ -0,0 +1,67 @@
<?php
namespace App\Console\Commands;
use App\Models\Setting;
use App\Services\SchneespurUpdater;
use Illuminate\Console\Command;
class UpdateCheck extends Command
{
protected $signature = 'schneespur:update-check
{--apply : Download and verify the ZIP after finding an update}';
protected $description = 'Check the update server for a new Schneespur version.';
public function handle(): int
{
if (! $this->option('apply') && ! Setting::get('auto_update_check', true)) {
return 0;
}
if (! function_exists('sodium_crypto_sign_verify_detached')) {
$this->error(__('update.sodium_missing'));
return 1;
}
try {
$updater = new SchneespurUpdater;
$manifest = $updater->checkForUpdate();
} catch (\Throwable $e) {
$this->error(__('update.artisan_check_failed', ['error' => $e->getMessage()]));
return 1;
}
if ($manifest === null) {
$this->info(__('update.artisan_up_to_date'));
return 0;
}
$this->info(__('update.artisan_update_available', [
'version' => $manifest['version'],
'counter' => $manifest['counter'],
'signed_at' => $manifest['signed_at'],
]));
if (! $this->option('apply')) {
$this->line(__('update.artisan_apply_hint'));
return 0;
}
try {
$zipPath = $updater->downloadAndVerifyZip($manifest);
} catch (\Throwable $e) {
$this->error(__('update.artisan_zip_failed', ['error' => $e->getMessage()]));
return 1;
}
$this->info(__('update.artisan_zip_verified', ['path' => $zipPath]));
return 0;
}
}

View file

@ -0,0 +1,83 @@
<?php
namespace App\Console\Commands;
use App\Services\SchneespurUpdater;
use Illuminate\Console\Command;
class UpdateRecover extends Command
{
protected $signature = 'schneespur:update-recover
{--force : Skip confirmation prompt}';
protected $description = 'Recover from a failed Schneespur update (restore backup + exit maintenance mode).';
public function handle(): int
{
$updater = new SchneespurUpdater;
$recovery = $updater->getRecoveryInfo();
if ($recovery === null) {
$this->info(__('update.recovery_no_info'));
if (app()->isDownForMaintenance()) {
$this->warn(__('update.recovery_still_maintenance'));
if ($this->option('force') || $this->confirm(__('update.recovery_confirm_up'))) {
\Illuminate\Support\Facades\Artisan::call('up');
$this->info(__('update.recovery_maintenance_disabled'));
}
}
return 0;
}
$this->error(__('update.recovery_found'));
$this->table(
['Key', 'Value'],
[
['Failed At', $recovery['failed_at'] ?? '?'],
['Target Version', $recovery['target_version'] ?? '?'],
['Error', $recovery['error'] ?? '?'],
['Backup Dir', $recovery['backup_dir'] ?? '?'],
]
);
if (! empty($recovery['recovery_steps'])) {
$this->line('');
$this->info(__('update.recovery_steps_title'));
foreach ($recovery['recovery_steps'] as $step) {
$this->line(" {$step}");
}
}
$backupDir = $recovery['backup_dir'] ?? null;
if ($backupDir && is_dir($backupDir)) {
$this->line('');
if ($this->option('force') || $this->confirm(__('update.recovery_confirm_restore'))) {
$ok = $updater->restoreFromBackup($backupDir);
if ($ok) {
$this->info(__('update.recovery_restore_success'));
} else {
$this->error(__('update.recovery_restore_failed'));
return 1;
}
}
} else {
$this->warn(__('update.recovery_no_backup'));
}
if (app()->isDownForMaintenance()) {
if ($this->option('force') || $this->confirm(__('update.recovery_confirm_up'))) {
\Illuminate\Support\Facades\Artisan::call('up');
$this->info(__('update.recovery_maintenance_disabled'));
}
}
$updater->clearRecoveryInfo();
$this->info(__('update.recovery_cleared'));
return 0;
}
}

View file

@ -0,0 +1,16 @@
<?php
namespace App\Enums;
enum JobType: string
{
case Raumen = 'raumen';
case Streuen = 'streuen';
case Kontrolle = 'kontrolle';
case RaumenStreuen = 'raumen_streuen';
public function label(): string
{
return __('job.type_' . $this->value);
}
}

View file

@ -0,0 +1,18 @@
<?php
namespace App\Enums;
enum UserRole: string
{
case Admin = 'admin';
case Driver = 'driver';
/**
* Return the human-readable label for this role.
* Uses the lang/de/admin.php (or current locale equivalent) keys.
*/
public function label(): string
{
return __('admin.role_' . $this->value);
}
}

View file

@ -0,0 +1,14 @@
<?php
namespace App\Enums;
enum WeatherMoment: string
{
case Start = 'start';
case End = 'end';
public function label(): string
{
return __('weather.moment_' . $this->value);
}
}

View file

@ -0,0 +1,16 @@
<?php
namespace App\Events;
use App\Models\Customer;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class CustomerCreated
{
use Dispatchable, SerializesModels;
public function __construct(
public Customer $customer,
) {}
}

View file

@ -0,0 +1,18 @@
<?php
namespace App\Events;
use App\Models\Job;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class JobCompleted
{
use Dispatchable, SerializesModels;
public function __construct(
public Job $job,
public bool $weatherAvailable,
public bool $isWeatherUpdate = false,
) {}
}

View file

@ -0,0 +1,16 @@
<?php
namespace App\Events;
use App\Models\Job;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class JobStarted
{
use Dispatchable, SerializesModels;
public function __construct(
public Job $job,
) {}
}

View file

@ -0,0 +1,16 @@
<?php
namespace App\Events;
use App\Models\WeatherSnapshot;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class WeatherSnapshotCreated
{
use Dispatchable, SerializesModels;
public function __construct(
public WeatherSnapshot $snapshot,
) {}
}

View file

@ -0,0 +1,33 @@
<?php
namespace App\Exceptions;
use RuntimeException;
class JobLifecycleException extends RuntimeException
{
public static function shiftAlreadyActive(): self
{
return new self('Eine Schicht ist bereits aktiv. Beende die aktuelle Schicht, bevor du eine neue startest.');
}
public static function noActiveShift(): self
{
return new self('Keine aktive Schicht vorhanden. Starte zuerst eine Schicht.');
}
public static function jobAlreadyActive(): self
{
return new self('Ein Einsatz ist bereits aktiv. Beende den aktuellen Einsatz, bevor du einen neuen startest.');
}
public static function noActiveJob(): self
{
return new self('Kein aktiver Einsatz vorhanden.');
}
public static function activeJobMustEndFirst(): self
{
return new self('Ein aktiver Einsatz muss zuerst beendet werden, bevor die Schicht beendet werden kann.');
}
}

View file

@ -0,0 +1,121 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Enums\JobType;
use App\Models\Customer;
use App\Models\CustomerObject;
use App\Models\Job;
use App\Models\User;
use App\Services\GpsSmoothingService;
use App\Services\JobAuditService;
use App\Services\PdfReportService;
use App\Services\RetentionService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\View\View;
class AdminJobController extends Controller
{
public function index(Request $request): View
{
$jobs = Job::query()
->with(['customer', 'customerObject.customer', 'user'])
->withCount('gpsPoints')
->when($request->started_after, fn ($q, $date) => $q->where('started_at', '>=', $date))
->when($request->started_before, fn ($q, $date) => $q->where('started_at', '<=', $date))
->when($request->user_id, fn ($q, $id) => $q->where('user_id', $id))
->when($request->customer_id, fn ($q, $id) => $q->where('customer_id', $id))
->when($request->customer_object_id, fn ($q, $id) => $q->where('customer_object_id', $id))
->when($request->type, fn ($q, $type) => $q->where('type', $type))
->orderByDesc('started_at')
->paginate(25)
->withQueryString();
$drivers = User::drivers()->orderBy('name')->get();
$customers = Customer::orderBy('name')->get();
$jobTypes = JobType::cases();
$objects = $request->customer_id
? CustomerObject::where('customer_id', $request->customer_id)->orderBy('name')->get()
: collect();
return view('admin.jobs.index', compact('jobs', 'drivers', 'customers', 'jobTypes', 'objects'));
}
public function show(Job $serviceJob, GpsSmoothingService $gpsSmoother): View
{
$serviceJob->load([
'customer',
'customerObject.customer',
'user',
'vehicle',
'gpsPoints' => fn ($q) => $q->orderBy('timestamp'),
'weatherSnapshots',
'jobPhotos' => fn ($q) => $q->orderBy('sort_order')->orderBy('created_at'),
'audits.user',
]);
$smoothedGps = $gpsSmoother->smooth($serviceJob->gpsPoints)
->map(fn ($p) => ['lat' => $p->lat, 'lon' => $p->lon]);
return view('admin.jobs.show', ['job' => $serviceJob, 'smoothedGps' => $smoothedGps]);
}
public function edit(Job $serviceJob): View
{
$this->authorize('update', $serviceJob);
$serviceJob->load(['customer', 'customerObject.customer', 'user']);
return view('admin.jobs.edit', ['job' => $serviceJob]);
}
public function update(Request $request, Job $serviceJob, JobAuditService $auditService): RedirectResponse
{
$this->authorize('update', $serviceJob);
$validated = $request->validate([
'notes' => ['nullable', 'string', 'max:2000'],
]);
$oldValues = ['notes' => $serviceJob->notes];
$serviceJob->update($validated);
$newValues = ['notes' => $serviceJob->notes];
$auditService->logChange($serviceJob, 'updated', $oldValues, $newValues);
return redirect()->route('admin.jobs.show', $serviceJob)
->with('success', __('job.edit_success'));
}
public function destroy(Request $request, Job $serviceJob, JobAuditService $auditService, RetentionService $retentionService): RedirectResponse
{
$this->authorize('delete', $serviceJob);
$request->validate([
'confirmation' => ['required', 'string'],
]);
if ($request->input('confirmation') !== __('job.delete_confirmation_word')) {
return back()->withErrors(['confirmation' => __('job.delete_confirm_mismatch')]);
}
$auditService->logDeletion($serviceJob);
$retentionService->deleteJob($serviceJob);
return redirect()->route('admin.jobs.index')
->with('success', __('job.delete_success'));
}
public function pdf(Job $serviceJob, PdfReportService $pdfService): Response
{
abort_if(is_null($serviceJob->ended_at), 422, __('job.pdf_active_blocked'));
$pdf = $pdfService->generateJobReport($serviceJob);
$filename = $pdfService->jobReportFilename($serviceJob);
return $pdf->download($filename);
}
}

View file

@ -0,0 +1,264 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Module;
use App\Services\ModuleManager;
use App\Services\SchneespurModuleClient;
use App\Services\SchneespurModuleInstaller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
class AdminModuleController extends Controller
{
public function index(SchneespurModuleClient $client): View
{
$installed = Module::all()->keyBy('slug');
$catalogModules = [];
$catalogError = null;
try {
$catalog = $client->fetchCatalog();
if ($catalog !== null) {
$catalogModules = $catalog['modules'] ?? [];
} else {
$state = $client->loadState();
$catalogModules = $state['installed'] ?? [];
}
} catch (\Throwable $e) {
Log::warning('schneespur-modules: catalog fetch failed in admin UI', [
'error' => $e->getMessage(),
]);
$catalogError = $e->getMessage();
}
$modules = [];
foreach ($catalogModules as $catModule) {
$slug = $catModule['slug'] ?? null;
if (! $slug) {
continue;
}
$local = $installed->get($slug);
$modules[$slug] = [
'slug' => $slug,
'name' => SchneespurModuleClient::i18nPick($catModule['name'] ?? [], app()->getLocale()),
'description' => SchneespurModuleClient::i18nPick($catModule['description'] ?? [], app()->getLocale()),
'catalog_version' => $catModule['version'] ?? null,
'category' => $catModule['category'] ?? null,
'image' => $catModule['image'] ?? null,
'installed' => $local !== null,
'enabled' => $local?->enabled ?? false,
'installed_version' => $local?->version,
'has_update' => $local !== null && isset($catModule['version']) && version_compare($catModule['version'], $local->version, '>'),
'is_orphan' => false,
'download_url' => $catModule['download_url'] ?? null,
'sha256' => $catModule['sha256'] ?? null,
'size_bytes' => $catModule['size_bytes'] ?? null,
'requires_permissions' => $catModule['requires_permissions'] ?? [],
];
}
foreach ($installed as $slug => $local) {
if (isset($modules[$slug])) {
continue;
}
$modules[$slug] = [
'slug' => $slug,
'name' => $local->name ?? $slug,
'description' => $local->description ?? '',
'catalog_version' => null,
'category' => $local->manifest_json['category'] ?? null,
'image' => $local->manifest_json['image'] ?? null,
'installed' => true,
'enabled' => $local->enabled,
'installed_version' => $local->version,
'has_update' => false,
'is_orphan' => true,
'download_url' => null,
'sha256' => null,
'size_bytes' => null,
'requires_permissions' => $this->resolveLocalPermissions($slug),
];
}
return view('admin.settings.modules.index', [
'modules' => $modules,
'catalogError' => $catalogError,
]);
}
public function install(Request $request, string $slug, SchneespurModuleClient $client, SchneespurModuleInstaller $installer): RedirectResponse
{
$catalog = null;
try {
$catalog = $client->fetchCatalog();
} catch (\Throwable $e) {
return redirect()->route('admin.settings.modules.index')
->with('error', __('modules.catalog_fetch_failed', ['error' => $e->getMessage()]));
}
if ($catalog === null) {
return redirect()->route('admin.settings.modules.index')
->with('error', __('modules.catalog_unavailable'));
}
$moduleData = collect($catalog['modules'] ?? [])->firstWhere('slug', $slug);
if (! $moduleData) {
return redirect()->route('admin.settings.modules.index')
->with('error', __('modules.not_found_in_catalog', ['slug' => $slug]));
}
try {
$zipPath = $client->downloadModule(
$slug,
$moduleData['download_url'],
$moduleData['sha256'],
$moduleData['size_bytes'],
);
$success = $installer->install($zipPath, $slug);
} catch (\Throwable $e) {
return redirect()->route('admin.settings.modules.index')
->with('error', __('modules.install_failed', ['slug' => $slug, 'error' => $e->getMessage()]));
} finally {
if (isset($zipPath)) {
@unlink($zipPath);
}
}
if (! $success) {
return redirect()->route('admin.settings.modules.index')
->with('error', __('modules.install_failed', ['slug' => $slug, 'error' => __('modules.directory_exists')]));
}
Module::updateOrCreate(
['slug' => $slug],
[
'version' => $moduleData['version'] ?? '0.0.0',
'enabled' => true,
'manifest_json' => $moduleData,
'installed_at' => now(),
],
);
return redirect()->route('admin.settings.modules.index')
->with('success', __('modules.installed', ['slug' => $slug]));
}
public function update(Request $request, string $slug, SchneespurModuleClient $client, SchneespurModuleInstaller $installer): RedirectResponse
{
$catalog = null;
try {
$catalog = $client->fetchCatalog();
} catch (\Throwable $e) {
return redirect()->route('admin.settings.modules.index')
->with('error', __('modules.catalog_fetch_failed', ['error' => $e->getMessage()]));
}
if ($catalog === null) {
return redirect()->route('admin.settings.modules.index')
->with('error', __('modules.catalog_unavailable'));
}
$moduleData = collect($catalog['modules'] ?? [])->firstWhere('slug', $slug);
if (! $moduleData) {
return redirect()->route('admin.settings.modules.index')
->with('error', __('modules.not_found_in_catalog', ['slug' => $slug]));
}
try {
$zipPath = $client->downloadModule(
$slug,
$moduleData['download_url'],
$moduleData['sha256'],
$moduleData['size_bytes'],
);
$success = $installer->update($zipPath, $slug);
} catch (\Throwable $e) {
return redirect()->route('admin.settings.modules.index')
->with('error', __('modules.update_failed', ['slug' => $slug, 'error' => $e->getMessage()]));
} finally {
if (isset($zipPath)) {
@unlink($zipPath);
}
}
if (! $success) {
return redirect()->route('admin.settings.modules.index')
->with('error', __('modules.update_failed', ['slug' => $slug, 'error' => __('modules.extraction_failed')]));
}
Module::where('slug', $slug)->update([
'version' => $moduleData['version'] ?? '0.0.0',
'manifest_json' => $moduleData,
]);
return redirect()->route('admin.settings.modules.index')
->with('success', __('modules.updated', ['slug' => $slug]));
}
public function enable(string $slug): RedirectResponse
{
$module = Module::where('slug', $slug)->first();
if (! $module) {
return redirect()->route('admin.settings.modules.index')
->with('error', __('modules.not_installed', ['slug' => $slug]));
}
$module->update(['enabled' => true]);
return redirect()->route('admin.settings.modules.index')
->with('success', __('modules.enabled', ['slug' => $slug]));
}
public function disable(string $slug): RedirectResponse
{
$module = Module::where('slug', $slug)->first();
if (! $module) {
return redirect()->route('admin.settings.modules.index')
->with('error', __('modules.not_installed', ['slug' => $slug]));
}
$module->update(['enabled' => false]);
return redirect()->route('admin.settings.modules.index')
->with('success', __('modules.disabled', ['slug' => $slug]));
}
private function resolveLocalPermissions(string $slug): array
{
try {
$manager = app(ModuleManager::class);
return $manager->getPermissions($slug);
} catch (\Throwable) {
return [];
}
}
public function remove(string $slug, SchneespurModuleInstaller $installer): RedirectResponse
{
$module = Module::where('slug', $slug)->first();
if (! $module) {
return redirect()->route('admin.settings.modules.index')
->with('error', __('modules.not_installed', ['slug' => $slug]));
}
$module->update(['enabled' => false]);
$installer->remove($slug);
$module->delete();
return redirect()->route('admin.settings.modules.index')
->with('success', __('modules.removed', ['slug' => $slug]));
}
}

View file

@ -0,0 +1,41 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Models\WorkShift;
use Illuminate\Http\Request;
use Illuminate\View\View;
class AdminWorkShiftController extends Controller
{
public function index(Request $request): View
{
$shifts = WorkShift::query()
->with('user')
->withCount('jobs')
->when($request->user_id, fn ($q, $id) => $q->where('user_id', $id))
->when($request->started_after, fn ($q, $date) => $q->where('started_at', '>=', $date))
->when($request->started_before, fn ($q, $date) => $q->where('started_at', '<=', $date))
->orderByDesc('started_at')
->paginate(25)
->withQueryString();
$drivers = User::drivers()->orderBy('name')->get();
return view('admin.workshifts.index', compact('shifts', 'drivers'));
}
public function show(WorkShift $workShift): View
{
$workShift->load(['user', 'jobs.customer']);
$duration = null;
if ($workShift->started_at && $workShift->ended_at) {
$duration = $workShift->started_at->diffForHumans($workShift->ended_at, true);
}
return view('admin.workshifts.show', compact('workShift', 'duration'));
}
}

View file

@ -0,0 +1,72 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Job;
use App\Services\AlertService;
use Illuminate\Http\Request;
class AlertController extends Controller
{
public function __construct(private AlertService $alertService) {}
public function index(Request $request)
{
$filters = $request->only(['type', 'date_from', 'date_to', 'status']);
$counts = $this->alertService->counts();
$type = $filters['type'] ?? null;
$alerts = null;
if ($type && in_array($type, ['missing_gps', 'missing_weather', 'overdue'])) {
$query = $this->alertService->forType($type, $filters);
$isResolved = ($filters['status'] ?? null) === 'resolved';
if ($isResolved) {
$query->with(['job.customer', 'job.user', 'resolvedBy']);
} else {
$query->with(['customer', 'user']);
}
$alerts = $query->paginate(15)->withQueryString();
}
return view('admin.alerts.index', [
'alerts' => $alerts,
'counts' => $counts,
'filters' => $filters,
]);
}
public function resolve(Request $request, Job $serviceJob)
{
$validated = $request->validate([
'alert_type' => 'required|in:missing_gps,missing_weather,overdue',
'note' => 'nullable|string|max:1000',
]);
$this->alertService->resolve(
$serviceJob->id,
$validated['alert_type'],
$validated['note'] ?? null,
$request->user()->id,
);
return redirect()->back()->with('success', __('alerts.resolved'));
}
public function bulkResolve(Request $request)
{
$validated = $request->validate([
'type' => 'required|in:missing_gps,missing_weather,overdue',
]);
$count = $this->alertService->bulkResolve(
$validated['type'],
$request->user()->id,
);
return redirect()->back()->with('success', __('alerts.bulk_resolved', ['count' => $count]));
}
}

View file

@ -0,0 +1,19 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\View\View;
class ArchivedDriverController extends Controller
{
public function index(): View
{
$drivers = User::onlyAnonymized()
->orderByDesc('anonymized_at')
->paginate(25);
return view('admin.drivers.archived', compact('drivers'));
}
}

View file

@ -0,0 +1,54 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Setting;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\View\View;
class BrandingController extends Controller
{
public function edit(): View
{
return view('admin.settings.branding', [
'logoPath' => Setting::get('company_logo_path'),
]);
}
public function update(Request $request): RedirectResponse
{
$request->validate([
'company_logo' => ['nullable', 'image', 'mimes:png,jpg,jpeg,svg', 'max:2048'],
]);
if ($request->hasFile('company_logo')) {
$oldPath = Setting::get('company_logo_path');
if ($oldPath && Storage::disk('public')->exists($oldPath)) {
Storage::disk('public')->delete($oldPath);
}
$path = $request->file('company_logo')->store('branding', 'public');
Setting::set('company_logo_path', $path);
}
return redirect()->route('admin.settings.branding')
->with('success', __('ui.saved'));
}
public function deleteLogo(): RedirectResponse
{
$path = Setting::get('company_logo_path');
if ($path && Storage::disk('public')->exists($path)) {
Storage::disk('public')->delete($path);
}
Setting::set('company_logo_path', '');
return redirect()->route('admin.settings.branding')
->with('success', __('ui.saved'));
}
}

View file

@ -0,0 +1,96 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Setting;
use App\Services\GeocodingService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class CompanyController extends Controller
{
private const LOCALES = [
'de' => 'Deutsch',
'en' => 'English',
];
public function edit(): View
{
return view('admin.settings.company', [
'company_name' => Setting::get('company_name', ''),
'company_street' => Setting::get('company_street', ''),
'company_zip' => Setting::get('company_zip', ''),
'company_city' => Setting::get('company_city', ''),
'company_phone' => Setting::get('company_phone', ''),
'company_email' => Setting::get('company_email', ''),
'company_lat' => Setting::get('company_lat'),
'company_lon' => Setting::get('company_lon'),
'dpo_contact' => Setting::get('dpo_contact', ''),
'dpo_email' => Setting::get('dpo_email', ''),
'season_from' => Setting::get('season_from', '11-01'),
'season_to' => Setting::get('season_to', '03-31'),
'alert_overdue_hours' => Setting::get('alert_overdue_hours', 4),
'default_locale' => Setting::get('default_locale', 'de'),
'locales' => self::LOCALES,
]);
}
public function update(Request $request, GeocodingService $geocoding): RedirectResponse
{
$validated = $request->validate([
'company_name' => ['required', 'string', 'max:255'],
'company_street' => ['nullable', 'string', 'max:255'],
'company_zip' => ['nullable', 'string', 'max:10'],
'company_city' => ['nullable', 'string', 'max:255'],
'company_phone' => ['nullable', 'string', 'max:50'],
'company_email' => ['nullable', 'email', 'max:255'],
'dpo_contact' => ['nullable', 'string', 'max:255'],
'dpo_email' => ['nullable', 'email', 'max:255'],
'season_from' => ['required', 'string', 'regex:/^\d{2}-\d{2}$/'],
'season_to' => ['required', 'string', 'regex:/^\d{2}-\d{2}$/'],
'alert_overdue_hours' => ['required', 'integer', 'min:1'],
'default_locale' => ['required', 'string', 'in:'.implode(',', array_keys(self::LOCALES))],
]);
Setting::set('company_name', $validated['company_name']);
Setting::set('company_street', $validated['company_street'] ?? '');
Setting::set('company_zip', $validated['company_zip'] ?? '');
Setting::set('company_city', $validated['company_city'] ?? '');
Setting::set('company_phone', $validated['company_phone'] ?? '');
Setting::set('company_email', $validated['company_email'] ?? '');
Setting::set('dpo_contact', $validated['dpo_contact'] ?? '');
Setting::set('dpo_email', $validated['dpo_email'] ?? '');
Setting::set('season_from', $validated['season_from']);
Setting::set('season_to', $validated['season_to']);
Setting::set('alert_overdue_hours', $validated['alert_overdue_hours'], 'int');
Setting::set('default_locale', $validated['default_locale']);
$street = $validated['company_street'] ?? '';
$zip = $validated['company_zip'] ?? '';
$city = $validated['company_city'] ?? '';
if ($street !== '' && $zip !== '' && $city !== '') {
$oldStreet = Setting::get('company_street', '');
$oldZip = Setting::get('company_zip', '');
$oldCity = Setting::get('company_city', '');
$result = $geocoding->resolve($street, $zip, $city);
if ($result) {
Setting::set('company_lat', (string) $result['lat']);
Setting::set('company_lon', (string) $result['lon']);
return redirect()->route('admin.settings.company')
->with('success', __('settings.company_geocode_success').' ('.$result['lat'].', '.$result['lon'].')');
}
return redirect()->route('admin.settings.company')
->with('warning', __('settings.company_geocode_fail'));
}
return redirect()->route('admin.settings.company')
->with('success', __('ui.saved'));
}
}

View file

@ -0,0 +1,62 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Customer;
use App\Models\User;
use App\Services\CsvExportService;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Carbon;
use Illuminate\View\View;
class CsvExportController extends Controller
{
public function __construct(
private readonly CsvExportService $csvExportService,
) {}
public function index(): View
{
$drivers = User::withAnonymized()->drivers()->orderBy('name')->get();
$customers = Customer::orderBy('name')->get();
return view('admin.exports.csv', [
'drivers' => $drivers,
'customers' => $customers,
'defaultFrom' => Carbon::now()->startOfMonth()->format('Y-m-d'),
'defaultTo' => Carbon::now()->format('Y-m-d'),
]);
}
public function download(Request $request): Response
{
$validated = $request->validate([
'from' => ['required', 'date'],
'to' => ['required', 'date', 'after_or_equal:from'],
'variant' => ['required', 'in:all,driver,customer'],
'user_id' => ['required_if:variant,driver', 'nullable', 'exists:users,id'],
'customer_id' => ['required_if:variant,customer', 'nullable', 'exists:customers,id'],
]);
$csv = $this->csvExportService->buildCsv(
variant: $validated['variant'],
from: $validated['from'],
to: $validated['to'],
userId: $validated['user_id'] ?? null,
customerId: $validated['customer_id'] ?? null,
);
$filename = $this->csvExportService->generateFilename(
variant: $validated['variant'],
from: $validated['from'],
to: $validated['to'],
);
return new Response($csv, 200, [
'Content-Type' => 'text/csv; charset=UTF-8',
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
]);
}
}

View file

@ -0,0 +1,93 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Events\CustomerCreated as CustomerCreatedEvent;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\StoreCustomerRequest;
use App\Http\Requests\Admin\UpdateCustomerRequest;
use App\Models\Customer;
use App\Services\GeocodingService;
use App\Services\NotificationLogService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class CustomerController extends Controller
{
public function index(Request $request): View
{
$customers = Customer::query()
->with('objects')
->when($request->search, function ($query, $search) {
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhereHas('objects', fn ($obj) => $obj->where('city', 'like', "%{$search}%"));
});
})
->orderBy('name')
->paginate(25)
->withQueryString();
return view('admin.customers.index', compact('customers'));
}
public function create(): View
{
return view('admin.customers.create');
}
public function store(StoreCustomerRequest $request): RedirectResponse
{
$customer = Customer::create($request->validated());
CustomerCreatedEvent::dispatch($customer);
return redirect()
->route('admin.customers.index')
->with('success', __('customer.flash_created', ['name' => $customer->name]));
}
public function edit(Customer $customer): View
{
return view('admin.customers.edit', compact('customer'));
}
public function update(UpdateCustomerRequest $request, Customer $customer): RedirectResponse
{
$customer->update($request->validated());
return redirect()
->route('admin.customers.index')
->with('success', __('customer.flash_updated', ['name' => $customer->name]));
}
public function geocode(Request $request, GeocodingService $geocoding): JsonResponse
{
$request->validate([
'street' => ['required', 'string'],
'zip' => ['required', 'string'],
'city' => ['required', 'string'],
]);
$result = $geocoding->resolve($request->street, $request->zip, $request->city);
if ($result) {
return response()->json($result);
}
return response()->json(['error' => __('customer.geocode_failed')], 422);
}
public function destroy(Customer $customer, NotificationLogService $notificationLogService): RedirectResponse
{
$name = $customer->name;
$notificationLogService->anonymizeForCustomer($customer);
$customer->delete();
return redirect()
->route('admin.customers.index')
->with('success', __('customer.flash_deleted', ['name' => $name]));
}
}

View file

@ -0,0 +1,65 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\StoreCustomerObjectRequest;
use App\Http\Requests\Admin\UpdateCustomerObjectRequest;
use App\Models\Customer;
use App\Models\CustomerObject;
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;
class CustomerObjectController extends Controller
{
public function index(Customer $customer): View
{
$objects = $customer->objects()->orderBy('name')->get();
return view('admin.customer_objects.index', compact('customer', 'objects'));
}
public function create(Customer $customer): View
{
return view('admin.customer_objects.create', compact('customer'));
}
public function store(StoreCustomerObjectRequest $request, Customer $customer): RedirectResponse
{
$object = $customer->objects()->create($request->validated());
return redirect()
->route('admin.customers.objects.index', $customer)
->with('success', __('customer_object.flash_created', ['name' => $object->name]));
}
public function edit(Customer $customer, CustomerObject $object): View
{
return view('admin.customer_objects.edit', compact('customer', 'object'));
}
public function update(UpdateCustomerObjectRequest $request, Customer $customer, CustomerObject $object): RedirectResponse
{
$object->update($request->validated());
return redirect()
->route('admin.customers.objects.index', $customer)
->with('success', __('customer_object.flash_updated', ['name' => $object->name]));
}
public function destroy(Customer $customer, CustomerObject $object): RedirectResponse
{
if ($object->serviceJobs()->exists()) {
return redirect()
->route('admin.customers.objects.index', $customer)
->with('error', __('customer_object.flash_delete_has_jobs', ['name' => $object->name]));
}
$name = $object->name;
$object->delete();
return redirect()
->route('admin.customers.objects.index', $customer)
->with('success', __('customer_object.flash_deleted', ['name' => $name]));
}
}

View file

@ -0,0 +1,78 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Customer;
use App\Models\Job;
use App\Services\PdfReportService;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\Response;
class CustomerPdfController extends Controller
{
public function __construct(
private readonly PdfReportService $pdfReportService,
) {}
public function index(Request $request): View
{
$customers = Customer::orderBy('name')->get();
return view('admin.exports.customer-pdf', [
'customers' => $customers,
'selectedCustomer' => $request->query('customer'),
'defaultFrom' => Carbon::now()->startOfMonth()->format('Y-m-d'),
'defaultTo' => Carbon::now()->format('Y-m-d'),
]);
}
public function generate(Request $request): Response
{
$validated = $request->validate([
'customer_id' => ['required', 'exists:customers,id'],
'from' => ['required', 'date'],
'to' => ['required', 'date', 'after_or_equal:from'],
'include_active' => ['sometimes', 'boolean'],
'confirmed' => ['sometimes', 'boolean'],
]);
$customer = Customer::findOrFail($validated['customer_id']);
$from = Carbon::parse($validated['from']);
$to = Carbon::parse($validated['to']);
$includeActive = (bool) ($validated['include_active'] ?? false);
$jobCount = Job::where('customer_id', $customer->id)
->where('started_at', '>=', $from)
->where('started_at', '<=', $to->copy()->endOfDay())
->when(! $includeActive, fn ($q) => $q->whereNotNull('ended_at'))
->count();
if ($jobCount === 0) {
return redirect()->back()
->withInput()
->with('error', __('export.pdf_no_jobs'));
}
if ($jobCount > 50 && ! ($validated['confirmed'] ?? false)) {
return redirect()->back()
->withInput()
->with('warning', __('export.pdf_warning_many_jobs', ['count' => $jobCount]));
}
try {
$pdf = $this->pdfReportService->generateCustomerReport($customer, $from, $to, $includeActive);
$filename = $this->pdfReportService->customerReportFilename($customer, $from, $to);
return $pdf->download($filename);
} catch (\Throwable $e) {
report($e);
return redirect()->back()
->withInput()
->with('error', __('export.pdf_no_jobs'));
}
}
}

View file

@ -0,0 +1,69 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Mail\PortalCredentialsMail;
use App\Models\Customer;
use App\Services\NotificationLogService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
class CustomerPortalController extends Controller
{
public function setupAccess(Request $request, Customer $customer, NotificationLogService $logService): RedirectResponse
{
if (! $customer->email) {
return redirect()
->route('admin.customers.edit', $customer)
->with('error', __('customer.portal_no_email'));
}
$isReset = $customer->getOriginal('password') !== null;
$plainPassword = Str::random(12);
$customer->password = $plainPassword;
$customer->portal_enabled = true;
$customer->save();
try {
Mail::to($customer->email)->send(new PortalCredentialsMail($customer, $plainPassword, $isReset));
$logService->logSentForCustomer($customer, 'portal_credentials', $customer->email, [
'action' => $isReset ? 'reset' : 'setup',
]);
$flashKey = $isReset ? 'customer.portal_flash_reset' : 'customer.portal_flash_setup';
return redirect()
->route('admin.customers.edit', $customer)
->with('success', __($flashKey, ['name' => $customer->name]));
} catch (\Throwable $e) {
$logService->logFailedForCustomer($customer, 'portal_credentials', $customer->email, $e->getMessage(), [
'action' => $isReset ? 'reset' : 'setup',
]);
return redirect()
->route('admin.customers.edit', $customer)
->with('error', __('customer.portal_flash_email_failed', ['name' => $customer->name]));
}
}
public function updateSettings(Request $request, Customer $customer): RedirectResponse
{
$validated = $request->validate([
'portal_enabled' => ['required', 'boolean'],
'portal_show_gps' => ['required', 'boolean'],
'portal_show_photos' => ['required', 'boolean'],
'portal_show_driver_name' => ['required', 'boolean'],
]);
$customer->update($validated);
return redirect()
->route('admin.customers.edit', $customer)
->with('success', __('customer.portal_flash_settings_updated', ['name' => $customer->name]));
}
}

View file

@ -0,0 +1,124 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Customer;
use App\Models\Job;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\View\View;
class CustomerReportController extends Controller
{
public function index(Request $request): View
{
$customers = Customer::orderBy('name')->get();
$from = $this->parseDate($request->input('from'), Carbon::now()->startOfMonth());
$to = $this->parseDate($request->input('to'), Carbon::now()->startOfDay());
$selectedCustomer = null;
$jobs = null;
$totalJobs = 0;
$totalMinutes = 0;
$driverCount = 0;
$jobTypeBreakdown = collect();
$avgDurationMinutes = 0;
$frequencyPerWeek = null;
$sammelPdfUrl = null;
$customerId = $request->input('customer');
if ($customerId) {
$selectedCustomer = Customer::find($customerId);
if ($selectedCustomer) {
$jobs = Job::with(['customerObject', 'user' => fn ($q) => $q->withAnonymized()])
->where('customer_id', $customerId)
->where('started_at', '>=', $from)
->where('started_at', '<', $to->copy()->addDay())
->orderBy('started_at')
->get();
$totalJobs = $jobs->count();
$totalMinutes = $jobs->sum(fn ($job) => $job->started_at->diffInMinutes($job->ended_at ?? $job->started_at));
$driverCount = $jobs->pluck('user_id')->unique()->count();
$jobTypeBreakdown = $jobs->groupBy(fn ($j) => $j->type->value)->map->count();
$avgDurationMinutes = $totalJobs > 0 ? intdiv($totalMinutes, $totalJobs) : 0;
$days = max(1, $from->diffInDays($to));
if ($days >= 7) {
$weeks = max(1, ceil($days / 7));
$frequencyPerWeek = round($totalJobs / $weeks, 1);
}
$sammelPdfUrl = route('admin.exports.customer-pdf', [
'customer' => $customerId,
'from' => $from->format('Y-m-d'),
'to' => $to->format('Y-m-d'),
]);
}
}
return view('admin.overview.customer-report', [
'customers' => $customers,
'selectedCustomer' => $selectedCustomer,
'from' => $from,
'to' => $to,
'quickFilters' => $this->buildQuickFilters(),
'jobs' => $jobs,
'totalJobs' => $totalJobs,
'totalMinutes' => $totalMinutes,
'driverCount' => $driverCount,
'jobTypeBreakdown' => $jobTypeBreakdown,
'avgDurationMinutes' => $avgDurationMinutes,
'frequencyPerWeek' => $frequencyPerWeek,
'sammelPdfUrl' => $sammelPdfUrl,
]);
}
private function parseDate(?string $input, Carbon $default): Carbon
{
try {
return $input ? Carbon::parse($input)->startOfDay() : $default;
} catch (\Exception) {
return $default;
}
}
private function buildQuickFilters(): array
{
$now = Carbon::now();
if ($now->month >= 11) {
$seasonFrom = Carbon::create($now->year, 11, 1);
$seasonTo = Carbon::create($now->year + 1, 3, 31);
} elseif ($now->month <= 3) {
$seasonFrom = Carbon::create($now->year - 1, 11, 1);
$seasonTo = Carbon::create($now->year, 3, 31);
} else {
$seasonFrom = Carbon::create($now->year - 1, 11, 1);
$seasonTo = Carbon::create($now->year, 3, 31);
}
return [
'week' => [
'from' => $now->copy()->startOfWeek()->format('Y-m-d'),
'to' => $now->format('Y-m-d'),
],
'month' => [
'from' => $now->copy()->startOfMonth()->format('Y-m-d'),
'to' => $now->format('Y-m-d'),
],
'30days' => [
'from' => $now->copy()->subDays(30)->format('Y-m-d'),
'to' => $now->format('Y-m-d'),
],
'season' => [
'from' => $seasonFrom->format('Y-m-d'),
'to' => $seasonTo->format('Y-m-d'),
],
];
}
}

View file

@ -0,0 +1,49 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Jobs\SendCustomerReportEmail;
use App\Models\Customer;
use App\Models\CustomerObject;
use App\Services\NotificationLogService;
use Carbon\Carbon;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class CustomerReportEmailController extends Controller
{
public function send(Request $request, NotificationLogService $notificationLogService): RedirectResponse
{
$validated = $request->validate([
'customer_id' => ['required', 'exists:customers,id'],
'customer_object_id' => ['nullable', 'exists:customer_objects,id'],
'from' => ['required', 'date'],
'to' => ['required', 'date', 'after_or_equal:from'],
]);
$customer = Customer::findOrFail($validated['customer_id']);
$object = isset($validated['customer_object_id'])
? CustomerObject::findOrFail($validated['customer_object_id'])
: null;
$recipient = $object
? ($object->contact_email ?? $customer->notification_email ?? $customer->email)
: ($customer->notification_email ?? $customer->email);
if (empty($recipient)) {
return redirect()->back()->with('error', __('notification.customer_report_email_no_email'));
}
$from = Carbon::parse($validated['from']);
$to = Carbon::parse($validated['to']);
if ($notificationLogService->wasRecentlySentToCustomer($customer, 'customer_report_email', $from, $to)) {
return redirect()->back()->with('error', __('notification.customer_report_email_duplicate'));
}
SendCustomerReportEmail::dispatch($customer, $from, $to, $object);
return redirect()->back()->with('success', __('notification.customer_report_email_sent'));
}
}

View file

@ -0,0 +1,26 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Setting;
use App\Services\Extension\DashboardWidgetRegistry;
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;
class DashboardController extends Controller
{
public function dismissOnboarding(): RedirectResponse
{
Setting::set('onboarding_dismissed', '1');
return redirect()->route('admin.dashboard');
}
public function index(DashboardWidgetRegistry $widgetRegistry): View
{
$widgets = $widgetRegistry->getWidgets();
return view('admin.dashboard', compact('widgets'));
}
}

View file

@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\AnonymizeDriverRequest;
use App\Models\User;
use App\Services\DriverAnonymizationService;
use Illuminate\Http\RedirectResponse;
class DriverAnonymizationController extends Controller
{
public function __invoke(
AnonymizeDriverRequest $request,
User $driver,
DriverAnonymizationService $service
): RedirectResponse {
$service->anonymize($driver, $request->validated('reason'));
return redirect()
->route('admin.drivers.index')
->with('success', __('driver.flash_anonymized'));
}
}

View file

@ -0,0 +1,77 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Enums\UserRole;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\StoreDriverRequest;
use App\Http\Requests\Admin\UpdateDriverRequest;
use App\Models\User;
use App\Models\Vehicle;
use App\Services\OwntracksCredentialService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class DriverController extends Controller
{
public function index(Request $request): View
{
$drivers = User::drivers()
->when($request->search, function ($query, $search) {
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
})
->orderBy('name')
->paginate(25)
->withQueryString();
return view('admin.drivers.index', compact('drivers'));
}
public function create(): View
{
return view('admin.drivers.create', [
'vehicles' => Vehicle::orderBy('name')->get(),
]);
}
public function store(StoreDriverRequest $request, OwntracksCredentialService $credentialService): RedirectResponse
{
$driver = User::create($request->safe()->only(['name', 'email', 'password', 'phone', 'notes', 'default_vehicle_id']));
$driver->role = UserRole::Driver;
$driver->save();
$credentials = $credentialService->generateCredentials($driver, $request->user());
session()->flash('owntracks_credentials', $credentials);
return redirect()
->route('admin.drivers.credentials', $driver)
->with('success', __('driver.flash_created', ['name' => $driver->name]));
}
public function edit(User $driver): View
{
return view('admin.drivers.edit', [
'driver' => $driver,
'vehicles' => Vehicle::orderBy('name')->get(),
]);
}
public function update(UpdateDriverRequest $request, User $driver): RedirectResponse
{
$driver->update($request->safe()->only(['name', 'email', 'phone', 'notes', 'default_vehicle_id']));
if ($request->validated('password')) {
$driver->password = $request->validated('password');
$driver->save();
}
return redirect()
->route('admin.drivers.index')
->with('success', __('driver.flash_updated', ['name' => $driver->name]));
}
}

View file

@ -0,0 +1,38 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\OwntracksCredentialService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class DriverCredentialController extends Controller
{
public function show(Request $request, User $driver): View|RedirectResponse
{
$credentials = session('owntracks_credentials');
if (! $credentials) {
return redirect()->route('admin.drivers.edit', $driver);
}
$driver->owntracks_password_revealed_at = now();
$driver->save();
return view('admin.drivers.credentials', compact('driver', 'credentials'));
}
public function rotate(Request $request, User $driver, OwntracksCredentialService $credentialService): RedirectResponse
{
$credentials = $credentialService->generateCredentials($driver, $request->user());
session()->flash('owntracks_credentials', $credentials);
return redirect()
->route('admin.drivers.credentials', $driver)
->with('success', __('driver.flash_rotated'));
}
}

View file

@ -0,0 +1,28 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\DriverExportService;
class DriverExportController extends Controller
{
public function __construct(
private readonly DriverExportService $exportService,
) {}
public function exportSingle(User $driver)
{
$path = $this->exportService->exportSingle($driver);
return response()->download($path, "fahrer-{$driver->id}-export.zip")->deleteFileAfterSend(true);
}
public function exportAll()
{
$path = $this->exportService->exportAll();
return response()->download($path, 'alle-fahrer-export.zip')->deleteFileAfterSend(true);
}
}

View file

@ -0,0 +1,121 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Job;
use App\Models\User;
use App\Models\WorkShift;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\View\View;
class DriverReportController extends Controller
{
public function index(Request $request): View
{
$drivers = User::withAnonymized()->drivers()->orderBy('name')->get();
$from = $this->parseDate($request->input('from'), Carbon::now()->startOfMonth());
$to = $this->parseDate($request->input('to'), Carbon::now()->startOfDay());
$selectedDriver = null;
$jobs = null;
$totalJobs = 0;
$totalMinutes = 0;
$customerCount = 0;
$jobTypeBreakdown = collect();
$shiftCount = 0;
$totalShiftMinutes = 0;
$avgShiftMinutes = 0;
$driverId = $request->input('driver');
if ($driverId) {
$selectedDriver = User::withAnonymized()->find($driverId);
if ($selectedDriver) {
$jobs = Job::with(['customer', 'customerObject.customer'])
->where('user_id', $driverId)
->where('started_at', '>=', $from)
->where('started_at', '<', $to->copy()->addDay())
->orderBy('started_at')
->get();
$totalJobs = $jobs->count();
$totalMinutes = $jobs->sum(fn ($job) => $job->started_at->diffInMinutes($job->ended_at ?? $job->started_at));
$customerCount = $jobs->pluck('customer_id')->unique()->count();
$jobTypeBreakdown = $jobs->groupBy(fn ($j) => $j->type->value)->map->count();
$shifts = WorkShift::where('user_id', $driverId)
->where('started_at', '>=', $from)
->where('started_at', '<', $to->copy()->addDay())
->get();
$shiftCount = $shifts->count();
$totalShiftMinutes = $shifts->sum(fn ($s) => $s->started_at->diffInMinutes($s->ended_at ?? $s->started_at));
$avgShiftMinutes = $shiftCount > 0 ? intdiv($totalShiftMinutes, $shiftCount) : 0;
}
}
return view('admin.overview.driver-report', [
'drivers' => $drivers,
'selectedDriver' => $selectedDriver,
'from' => $from,
'to' => $to,
'quickFilters' => $this->buildQuickFilters(),
'jobs' => $jobs,
'totalJobs' => $totalJobs,
'totalMinutes' => $totalMinutes,
'customerCount' => $customerCount,
'jobTypeBreakdown' => $jobTypeBreakdown,
'shiftCount' => $shiftCount,
'totalShiftMinutes' => $totalShiftMinutes,
'avgShiftMinutes' => $avgShiftMinutes,
]);
}
private function parseDate(?string $input, Carbon $default): Carbon
{
try {
return $input ? Carbon::parse($input)->startOfDay() : $default;
} catch (\Exception) {
return $default;
}
}
private function buildQuickFilters(): array
{
$now = Carbon::now();
if ($now->month >= 11) {
$seasonFrom = Carbon::create($now->year, 11, 1);
$seasonTo = Carbon::create($now->year + 1, 3, 31);
} elseif ($now->month <= 3) {
$seasonFrom = Carbon::create($now->year - 1, 11, 1);
$seasonTo = Carbon::create($now->year, 3, 31);
} else {
$seasonFrom = Carbon::create($now->year - 1, 11, 1);
$seasonTo = Carbon::create($now->year, 3, 31);
}
return [
'week' => [
'from' => $now->copy()->startOfWeek()->format('Y-m-d'),
'to' => $now->format('Y-m-d'),
],
'month' => [
'from' => $now->copy()->startOfMonth()->format('Y-m-d'),
'to' => $now->format('Y-m-d'),
],
'30days' => [
'from' => $now->copy()->subDays(30)->format('Y-m-d'),
'to' => $now->format('Y-m-d'),
],
'season' => [
'from' => $seasonFrom->format('Y-m-d'),
'to' => $seasonTo->format('Y-m-d'),
],
];
}
}

View file

@ -0,0 +1,118 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\DsgvoConfirmation;
use App\Models\Setting;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Str;
use Illuminate\View\View;
class DsgvoAdminController extends Controller
{
public function index(): View
{
$markdown = Setting::get('dsgvo_template_markdown');
$version = (int) Setting::get('dsgvo_template_version', 1);
if ($markdown === null) {
$markdown = view('dsgvo.default-template')->render();
}
$previewHtml = Str::markdown($this->replacePlaceholders($markdown), ['html_input' => 'strip']);
$confirmationCount = DsgvoConfirmation::where('template_version', $version)->count();
return view('admin.dsgvo.index', [
'markdown' => $markdown,
'previewHtml' => $previewHtml,
'version' => $version,
'confirmationCount' => $confirmationCount,
]);
}
public function update(Request $request)
{
$validated = $request->validate([
'markdown' => 'required|string|min:50|max:200000',
'substantial_change' => 'nullable|boolean',
]);
Setting::set('dsgvo_template_markdown', $validated['markdown']);
if ($request->boolean('substantial_change')) {
$currentVersion = (int) Setting::get('dsgvo_template_version', 1);
Setting::set('dsgvo_template_version', $currentVersion + 1, 'int');
return redirect()->back()
->with('success', __('dsgvo.flash_template_updated_substantial'));
}
return redirect()->back()
->with('success', __('dsgvo.flash_template_updated'));
}
public function preview(Request $request): Response
{
$request->validate([
'markdown' => 'required|string',
]);
$html = Str::markdown($this->replacePlaceholders($request->markdown), ['html_input' => 'strip']);
return new Response($html, 200, ['Content-Type' => 'text/html']);
}
public function confirmations(Request $request): View
{
$query = DsgvoConfirmation::with('driver')
->orderByDesc('confirmed_at');
if ($search = $request->input('search')) {
$query->whereHas('driver', fn ($q) => $q->where('name', 'like', "%{$search}%"));
}
$confirmations = $query->paginate(25)->withQueryString();
return view('admin.dsgvo.confirmations', [
'confirmations' => $confirmations,
]);
}
private function replacePlaceholders(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", ', ');
$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
{
$confirmation = DsgvoConfirmation::findOrFail($id);
$confirmation->load('driver');
$snapshotHtml = Str::markdown($confirmation->notice_text_snapshot, ['html_input' => 'strip']);
return view('admin.dsgvo.confirmation-show', [
'confirmation' => $confirmation,
'snapshotHtml' => $snapshotHtml,
]);
}
}

View file

@ -0,0 +1,128 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Services\Installer\EnvFileWriter;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Mail;
use Illuminate\View\View;
class EmailSettingsController extends Controller
{
private const PASSWORD_SENTINEL = '••••••••';
private const MAIL_KEYS = [
'MAIL_MAILER',
'MAIL_HOST',
'MAIL_PORT',
'MAIL_SCHEME',
'MAIL_USERNAME',
'MAIL_FROM_ADDRESS',
'MAIL_FROM_NAME',
];
public function edit(EnvFileWriter $envWriter): View
{
$config = [];
foreach (self::MAIL_KEYS as $key) {
$value = $envWriter->get($key) ?? '';
if ($value === 'null') {
$value = '';
}
if (preg_match('/\$\{(.+?)\}/', $value, $m)) {
$value = env($m[1], $value);
}
$config[$key] = $value;
}
$config['MAIL_MAILER'] = $config['MAIL_MAILER'] ?: 'smtp';
$envWritable = $envWriter->isWritable();
$envContent = '';
if (! $envWritable) {
$lines = [];
foreach (self::MAIL_KEYS as $key) {
$lines[] = $key . '=' . $config[$key];
}
$lines[] = 'MAIL_PASSWORD=your-password-here';
$envContent = implode("\n", $lines);
}
return view('admin.settings.email', [
'config' => $config,
'envWritable' => $envWritable,
'envContent' => $envContent,
'passwordSentinel' => self::PASSWORD_SENTINEL,
]);
}
public function update(Request $request, EnvFileWriter $envWriter): RedirectResponse
{
$request->validate([
'mail_mailer' => 'required|string',
'mail_host' => 'required|string|max:255',
'mail_port' => 'required|integer|min:1|max:65535',
'mail_scheme' => 'required|in:null,tls,ssl',
'mail_username' => 'nullable|string|max:255',
'mail_password' => 'nullable|string|max:255',
'mail_from_address' => 'required|email|max:255',
'mail_from_name' => 'required|string|max:255',
]);
if (! $envWriter->isWritable()) {
return redirect()->back()->with('error', __('notification.env_not_writable'));
}
$schemeInput = $request->input('mail_scheme');
$schemeMap = ['tls' => '', 'ssl' => 'smtps', 'null' => ''];
$mailScheme = $schemeMap[$schemeInput] ?? '';
$values = [
'MAIL_MAILER' => $request->input('mail_mailer'),
'MAIL_HOST' => $request->input('mail_host'),
'MAIL_PORT' => $request->input('mail_port'),
'MAIL_SCHEME' => $mailScheme,
'MAIL_USERNAME' => $request->input('mail_username', ''),
'MAIL_FROM_ADDRESS' => $request->input('mail_from_address'),
'MAIL_FROM_NAME' => $request->input('mail_from_name'),
];
$password = $request->input('mail_password');
if ($password !== null && $password !== '' && $password !== self::PASSWORD_SENTINEL) {
$values['MAIL_PASSWORD'] = $password;
}
try {
$envWriter->setMany($values);
} catch (\Throwable $e) {
return redirect()->back()->with('error', __('notification.env_not_writable'));
}
Artisan::call('config:clear');
return redirect()->back()->with('success', __('notification.email_saved'));
}
public function sendTest(Request $request): RedirectResponse
{
$request->validate([
'test_recipient' => 'required|email|max:255',
]);
$recipient = $request->input('test_recipient');
try {
Mail::raw(__('notification.test_email_body', ['app_name' => brand()]), function ($message) use ($recipient) {
$message->to($recipient)
->subject(__('notification.test_email_subject', ['app_name' => brand()]));
});
} catch (\Throwable $e) {
return redirect()->back()->withInput()->with('error', __('notification.test_email_failed') . ': ' . $e->getMessage());
}
return redirect()->back()->with('success', __('notification.test_email_sent_to', ['email' => $recipient]));
}
}

View file

@ -0,0 +1,43 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
class HelpController extends Controller
{
private const TOPICS = [
'installation' => 'help.topic_installation',
'first-steps' => 'help.topic_first_steps',
'customers' => 'help.topic_customers',
'drivers' => 'help.topic_drivers',
'owntracks' => 'help.topic_owntracks',
'jobs' => 'help.topic_jobs',
'overview' => 'help.topic_overview',
'exports' => 'help.topic_exports',
'dsgvo' => 'help.topic_dsgvo',
'settings' => 'help.topic_settings',
'updates' => 'help.topic_updates',
'modules' => 'help.topic_modules',
];
public function index()
{
return view('admin.help.index', [
'topics' => self::TOPICS,
]);
}
public function show(string $topic)
{
if (! array_key_exists($topic, self::TOPICS)) {
abort(404);
}
return view('admin.help.show', [
'topic' => $topic,
'topics' => self::TOPICS,
'langKey' => self::TOPICS[$topic],
]);
}
}

View file

@ -0,0 +1,54 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Enums\JobType;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\StoreManualJobRequest;
use App\Models\Customer;
use App\Models\CustomerObject;
use App\Models\User;
use App\Models\Vehicle;
use App\Services\JobLifecycleService;
use Carbon\Carbon;
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;
class ManualJobController extends Controller
{
public function __construct(
private readonly JobLifecycleService $service,
) {}
public function create(): View
{
return view('admin.jobs.manual.create', [
'customers' => Customer::with('objects')->orderBy('name')->get(),
'drivers' => User::drivers()->get(),
'vehicles' => Vehicle::all(),
'jobTypes' => JobType::cases(),
]);
}
public function store(StoreManualJobRequest $request): RedirectResponse
{
$validated = $request->validated();
$driver = User::findOrFail($validated['user_id']);
$customerObject = CustomerObject::findOrFail($validated['customer_object_id']);
$vehicle = isset($validated['vehicle_id']) ? Vehicle::find($validated['vehicle_id']) : null;
$this->service->createManualJob(
driver: $driver,
customerObject: $customerObject,
type: JobType::from($validated['type']),
startedAt: Carbon::parse($validated['started_at']),
endedAt: Carbon::parse($validated['ended_at']),
notes: $validated['notes'] ?? null,
vehicle: $vehicle,
);
return redirect()->route('admin.jobs.manual.create')
->with('success', __('job.manual_created'));
}
}

View file

@ -0,0 +1,33 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Job;
use App\Models\NotificationLog;
use Illuminate\Http\Request;
use Illuminate\View\View;
class NotificationLogController extends Controller
{
public function index(Request $request): View
{
$logs = NotificationLog::query()
->select('notification_logs.*')
->leftJoin('service_jobs', function ($join) {
$join->on('notification_logs.notifiable_id', '=', 'service_jobs.id')
->where('notification_logs.notifiable_type', '=', Job::class);
})
->leftJoin('customers', 'service_jobs.customer_id', '=', 'customers.id')
->addSelect('customers.name as customer_name')
->when($request->status, fn ($q, $status) => $q->where('notification_logs.status', $status))
->when($request->type, fn ($q, $type) => $q->where('notification_logs.type', $type))
->when($request->date_from, fn ($q, $date) => $q->where('notification_logs.created_at', '>=', $date))
->when($request->date_to, fn ($q, $date) => $q->where('notification_logs.created_at', '<=', $date . ' 23:59:59'))
->orderByDesc('notification_logs.created_at')
->paginate(25)
->withQueryString();
return view('admin.settings.notification-log', compact('logs'));
}
}

View file

@ -0,0 +1,207 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Job;
use App\Models\User;
use App\Models\WeatherSnapshot;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View;
class OverviewController extends Controller
{
public function daily(Request $request): View
{
$date = $this->parseDate($request->input('date'));
$dayStart = $date->copy()->startOfDay();
$dayEnd = $date->copy()->addDay()->startOfDay();
$jobs = Job::with(['customer', 'customerObject.customer', 'user' => fn ($q) => $q->withAnonymized()])
->where('started_at', '>=', $dayStart)
->where('started_at', '<', $dayEnd)
->orderBy('started_at')
->get();
$driverSummaries = $jobs->groupBy('user_id')->map(function ($driverJobs) {
$user = $driverJobs->first()->user;
$totalMinutes = $driverJobs->sum(function ($job) {
if (!$job->ended_at) {
return 0;
}
return $job->started_at->diffInMinutes($job->ended_at);
});
$typeCounts = $driverJobs->groupBy(fn ($j) => $j->type->value)->map->count();
return (object) [
'user' => $user,
'jobs' => $driverJobs,
'job_count' => $driverJobs->count(),
'total_minutes' => $totalMinutes,
'type_counts' => $typeCounts,
];
});
$totalJobs = $jobs->count();
$totalMinutes = $driverSummaries->sum('total_minutes');
$jobTypeBreakdown = $jobs->groupBy(fn ($j) => $j->type->value)->map->count();
$weatherSummary = $this->buildWeatherSummary($jobs);
$lastJobDate = null;
if ($totalJobs === 0) {
$lastStarted = Job::orderByDesc('started_at')->value('started_at');
$lastJobDate = $lastStarted ? Carbon::parse($lastStarted)->startOfDay() : null;
}
return view('admin.overview.daily', [
'date' => $date,
'driverSummaries' => $driverSummaries,
'totalJobs' => $totalJobs,
'totalMinutes' => $totalMinutes,
'jobTypeBreakdown' => $jobTypeBreakdown,
'weatherSummary' => $weatherSummary,
'lastJobDate' => $lastJobDate,
'prevDate' => $date->copy()->subDay(),
'nextDate' => $date->copy()->addDay(),
]);
}
public function monthly(Request $request): View
{
$month = $this->parseMonth($request->input('month'));
$monthStart = $month->copy()->startOfMonth();
$monthEnd = $month->copy()->endOfMonth()->addDay()->startOfDay();
$isSqlite = DB::getDriverName() === 'sqlite';
$durationExpr = $isSqlite
? "SUM((JULIANDAY(COALESCE(ended_at, started_at)) - JULIANDAY(started_at)) * 1440)"
: "SUM(TIMESTAMPDIFF(MINUTE, started_at, COALESCE(ended_at, started_at)))";
$dailyCounts = Job::select(
DB::raw('DATE(started_at) as job_date'),
DB::raw('COUNT(*) as job_count'),
DB::raw("{$durationExpr} as total_minutes")
)
->where('started_at', '>=', $monthStart)
->where('started_at', '<', $monthEnd)
->groupBy(DB::raw('DATE(started_at)'))
->get()
->keyBy('job_date');
$monthTotal = $dailyCounts->sum('job_count');
$totalMinutes = (int) $dailyCounts->sum('total_minutes');
$activeDriverCount = Job::where('started_at', '>=', $monthStart)
->where('started_at', '<', $monthEnd)
->distinct('user_id')
->count('user_id');
$monthKeyExpr = $isSqlite
? "strftime('%Y-%m', started_at)"
: "DATE_FORMAT(started_at, '%Y-%m')";
$activeMonths = Job::select(
DB::raw("{$monthKeyExpr} as month_key"),
DB::raw('COUNT(*) as job_count')
)
->groupBy('month_key')
->orderByDesc('month_key')
->get()
->keyBy('month_key');
return view('admin.overview.monthly', [
'month' => $month,
'dailyCounts' => $dailyCounts,
'monthTotal' => $monthTotal,
'totalMinutes' => $totalMinutes,
'activeDriverCount' => $activeDriverCount,
'activeMonths' => $activeMonths,
'prevMonth' => $month->copy()->subMonth(),
'nextMonth' => $month->copy()->addMonth(),
]);
}
public function dayDetail(Request $request): View
{
$date = $this->parseDate($request->input('date'));
$dayStart = $date->copy()->startOfDay();
$dayEnd = $date->copy()->addDay()->startOfDay();
$jobs = Job::with(['customer', 'customerObject.customer', 'user' => fn ($q) => $q->withAnonymized()])
->where('started_at', '>=', $dayStart)
->where('started_at', '<', $dayEnd)
->orderBy('started_at')
->get();
$driverSummaries = $jobs->groupBy('user_id')->map(function ($driverJobs) {
$user = $driverJobs->first()->user;
$totalMinutes = $driverJobs->sum(function ($job) {
if (!$job->ended_at) {
return 0;
}
return $job->started_at->diffInMinutes($job->ended_at);
});
return (object) [
'user' => $user,
'jobs' => $driverJobs,
'job_count' => $driverJobs->count(),
'total_minutes' => $totalMinutes,
];
});
$totalJobs = $jobs->count();
$totalMinutes = $driverSummaries->sum('total_minutes');
return view('admin.overview.partials.day-detail', [
'date' => $date,
'driverSummaries' => $driverSummaries,
'totalJobs' => $totalJobs,
'totalMinutes' => $totalMinutes,
'isInline' => true,
]);
}
private function parseDate(?string $input): Carbon
{
try {
return $input ? Carbon::parse($input)->startOfDay() : Carbon::today();
} catch (\Exception) {
return Carbon::today();
}
}
private function parseMonth(?string $input): Carbon
{
try {
return $input ? Carbon::parse($input)->startOfMonth() : Carbon::today()->startOfMonth();
} catch (\Exception) {
return Carbon::today()->startOfMonth();
}
}
private function buildWeatherSummary($jobs): ?object
{
$jobIds = $jobs->pluck('id');
if ($jobIds->isEmpty()) {
return null;
}
$snapshots = WeatherSnapshot::whereIn('job_id', $jobIds)->get();
if ($snapshots->isEmpty()) {
return null;
}
$temps = $snapshots->pluck('temperature')->filter()->values();
$hasPrecipitation = $snapshots->contains(fn ($s) => $s->precipitation > 0);
return (object) [
'temp_min' => $temps->isNotEmpty() ? $temps->min() : null,
'temp_max' => $temps->isNotEmpty() ? $temps->max() : null,
'has_precipitation' => $hasPrecipitation,
];
}
}

View file

@ -0,0 +1,51 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\GpsPoint;
use App\Models\Job;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View;
class OwntracksOverviewController extends Controller
{
public function __invoke(): View
{
$drivers = User::drivers()->orderBy('name')->get();
$driverIds = $drivers->pluck('id');
$latestGps = collect();
$activeJobs = collect();
if ($driverIds->isNotEmpty()) {
$latestGps = GpsPoint::select('gps_points.*')
->whereIn('gps_points.user_id', $driverIds)
->joinSub(
GpsPoint::select('user_id', DB::raw('MAX(timestamp) as max_ts'))
->whereIn('user_id', $driverIds)
->groupBy('user_id'),
'latest',
fn ($join) => $join->on('gps_points.user_id', '=', 'latest.user_id')
->on('gps_points.timestamp', '=', 'latest.max_ts')
)
->get()
->keyBy('user_id');
$activeJobs = Job::with('customer')
->whereIn('user_id', $driverIds)
->whereNull('ended_at')
->whereHas('workShift', fn ($q) => $q->whereNull('ended_at'))
->get()
->keyBy('user_id');
}
return view('admin.owntracks.overview', [
'drivers' => $drivers,
'latestGps' => $latestGps,
'activeJobs' => $activeJobs,
'now' => now()->timestamp,
]);
}
}

View file

@ -0,0 +1,34 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Setting;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class RetentionController extends Controller
{
public function edit(): View
{
return view('admin.settings.retention', [
'retention_years' => Setting::get('retention_years', 3),
'retention_auto_delete' => Setting::get('retention_auto_delete', false),
]);
}
public function update(Request $request): RedirectResponse
{
$validated = $request->validate([
'retention_years' => ['required', 'integer', 'min:3'],
'retention_auto_delete' => ['nullable', 'boolean'],
]);
Setting::set('retention_years', $validated['retention_years'], 'int');
Setting::set('retention_auto_delete', $request->boolean('retention_auto_delete'), 'bool');
return redirect()->route('admin.settings.retention')
->with('success', __('ui.saved'));
}
}

View file

@ -0,0 +1,150 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Setting;
use App\Services\SchneespurUpdater;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class UpdateSettingsController extends Controller
{
public function edit(): View
{
$hasSodium = function_exists('sodium_crypto_sign_verify_detached');
$state = null;
if ($hasSodium) {
try {
$state = (new SchneespurUpdater)->getState();
} catch (\Throwable) {
// Config missing or corrupt — show page anyway
}
}
$preflight = null;
if ($hasSodium) {
try {
$preflight = (new SchneespurUpdater)->canInstall();
} catch (\Throwable) {
}
}
return view('admin.settings.update', [
'hasSodium' => $hasSodium,
'autoCheck' => Setting::get('auto_update_check', true),
'currentVersion' => config('app.version', '1.0.0'),
'state' => $state,
'preflight' => $preflight,
]);
}
public function update(Request $request): RedirectResponse
{
Setting::set('auto_update_check', $request->boolean('auto_update_check'), 'bool');
return redirect()->route('admin.settings.update')
->with('success', __('ui.saved'));
}
public function checkNow(): JsonResponse
{
if (! function_exists('sodium_crypto_sign_verify_detached')) {
return response()->json([
'ok' => false,
'message' => __('update.sodium_missing'),
]);
}
try {
$updater = new SchneespurUpdater;
$manifest = $updater->checkForUpdate();
} catch (\Throwable $e) {
return response()->json([
'ok' => false,
'message' => __('update.check_result_error', ['error' => $e->getMessage()]),
]);
}
if ($manifest === null) {
return response()->json([
'ok' => true,
'update' => false,
'message' => __('update.check_result_up_to_date', ['app_name' => config('app.name')]),
]);
}
$locale = app()->getLocale();
return response()->json([
'ok' => true,
'update' => true,
'message' => __('update.check_result_update', ['version' => $manifest['version']]),
'version' => $manifest['version'],
'changelog' => $manifest['changelog'][$locale] ?? $manifest['changelog']['de'] ?? '',
'name' => $manifest['name'][$locale] ?? $manifest['name']['de'] ?? '',
'description' => $manifest['description'][$locale] ?? $manifest['description']['de'] ?? '',
'size_bytes' => $manifest['size_bytes'] ?? null,
'signed_at' => $manifest['signed_at'] ?? null,
]);
}
public function install(): JsonResponse
{
if (! function_exists('sodium_crypto_sign_verify_detached')) {
return response()->json([
'ok' => false,
'message' => __('update.sodium_missing'),
]);
}
try {
$updater = new SchneespurUpdater;
$manifest = $updater->checkForUpdate();
} catch (\Throwable $e) {
return response()->json([
'ok' => false,
'message' => __('update.check_result_error', ['error' => $e->getMessage()]),
]);
}
if ($manifest === null) {
return response()->json([
'ok' => true,
'update' => false,
'message' => __('update.check_result_up_to_date', ['app_name' => config('app.name')]),
]);
}
$preflight = $updater->canInstall();
if (in_array(false, $preflight, true)) {
return response()->json([
'ok' => false,
'message' => __('update.preflight_fail'),
'checks' => $preflight,
]);
}
try {
$zipPath = $updater->downloadAndVerifyZip($manifest);
$updater->install($zipPath, $manifest);
} catch (\Throwable $e) {
return response()->json([
'ok' => false,
'message' => __('update.install_failed', ['error' => $e->getMessage()]),
]);
} finally {
if (isset($zipPath)) {
@unlink($zipPath);
}
}
return response()->json([
'ok' => true,
'message' => __('update.install_success', ['version' => $manifest['version']]),
]);
}
}

View file

@ -0,0 +1,65 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\StoreVehicleRequest;
use App\Http\Requests\Admin\UpdateVehicleRequest;
use App\Models\Vehicle;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class VehicleController extends Controller
{
public function index(Request $request): View
{
$vehicles = Vehicle::query()
->when($request->search, function ($query, $search) {
$query->where('name', 'like', "%{$search}%");
})
->orderBy('name')
->paginate(25)
->withQueryString();
return view('admin.vehicles.index', compact('vehicles'));
}
public function create(): View
{
return view('admin.vehicles.create');
}
public function store(StoreVehicleRequest $request): RedirectResponse
{
$vehicle = Vehicle::create($request->validated());
return redirect()
->route('admin.vehicles.index')
->with('success', __('vehicle.flash_created', ['name' => $vehicle->name]));
}
public function edit(Vehicle $vehicle): View
{
return view('admin.vehicles.edit', compact('vehicle'));
}
public function update(UpdateVehicleRequest $request, Vehicle $vehicle): RedirectResponse
{
$vehicle->update($request->validated());
return redirect()
->route('admin.vehicles.index')
->with('success', __('vehicle.flash_updated', ['name' => $vehicle->name]));
}
public function destroy(Vehicle $vehicle): RedirectResponse
{
$name = $vehicle->name;
$vehicle->delete();
return redirect()
->route('admin.vehicles.index')
->with('success', __('vehicle.flash_deleted', ['name' => $name]));
}
}

View file

@ -0,0 +1,40 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Enums\WeatherMoment;
use App\Http\Controllers\Controller;
use App\Jobs\FetchWeather;
use App\Models\Job;
use Illuminate\Http\RedirectResponse;
class WeatherRetryController extends Controller
{
public function __invoke(Job $serviceJob, string $moment): RedirectResponse
{
$weatherMoment = WeatherMoment::from($moment);
$object = $serviceJob->customerObject;
$lat = null;
$lon = null;
if ($object !== null && $object->lat !== null && $object->lon !== null) {
$lat = (float) $object->lat;
$lon = (float) $object->lon;
} else {
$gpsPoint = $serviceJob->gpsPoints()->latest('timestamp')->first();
if ($gpsPoint !== null && $gpsPoint->lat !== null && $gpsPoint->lon !== null) {
$lat = (float) $gpsPoint->lat;
$lon = (float) $gpsPoint->lon;
}
}
if ($lat === null || $lon === null) {
return redirect()->back()->with('error', __('weather.retry_no_coordinates'));
}
FetchWeather::dispatch($serviceJob->id, $weatherMoment, $lat, $lon);
return redirect()->back()->with('success', __('weather.retry_dispatched'));
}
}

View file

@ -0,0 +1,71 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Setting;
use App\Services\Weather\WeatherProviderRegistry;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class WeatherSettingsController extends Controller
{
public function edit(WeatherProviderRegistry $registry): View
{
return view('admin.settings.weather', [
'providers' => $registry->availableProviders(),
'activeProvider' => $registry->activeSlug(),
'apiKey' => Setting::get('weather_api_key', ''),
'userAgentEmail' => Setting::get('weather_user_agent_email', ''),
'cacheTtlMinutes' => (int) (Setting::get('weather_cache_ttl', 300) / 60),
]);
}
public function update(Request $request, WeatherProviderRegistry $registry): RedirectResponse
{
$providerSlugs = array_keys($registry->availableProviders());
$validated = $request->validate([
'weather_provider' => ['required', 'string', 'in:'.implode(',', $providerSlugs)],
'weather_api_key' => ['nullable', 'string', 'max:255'],
'weather_user_agent_email' => ['nullable', 'email', 'max:255'],
'weather_cache_ttl' => ['required', 'integer', 'min:1'],
]);
Setting::set('weather_provider', $validated['weather_provider']);
Setting::set('weather_api_key', $validated['weather_api_key'] ?? '');
Setting::set('weather_user_agent_email', $validated['weather_user_agent_email'] ?? '');
Setting::set('weather_cache_ttl', $validated['weather_cache_ttl'] * 60, 'int');
return redirect()->route('admin.settings.weather')
->with('success', __('weather.settings_saved'));
}
public function testConnection(Request $request, WeatherProviderRegistry $registry): JsonResponse
{
$request->validate([
'provider' => ['required', 'string'],
]);
$slug = $request->input('provider');
if (! $registry->has($slug)) {
return response()->json([
'ok' => false,
'message' => 'Unknown provider',
'latency_ms' => 0,
]);
}
$provider = $registry->resolve($slug);
$lat = (float) Setting::get('company_lat', 48.1351);
$lon = (float) Setting::get('company_lon', 11.5820);
$result = $provider->testConnection($lat, $lon);
return response()->json($result);
}
}

View file

@ -0,0 +1,49 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\GpsPoint;
use App\Services\JobLifecycleService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class OwnTracksController extends Controller
{
public function store(Request $request): JsonResponse
{
if ($request->input('_type') !== 'location') {
return response()->json([], 200);
}
$validated = $request->validate([
'lat' => 'required|numeric',
'lon' => 'required|numeric',
'tst' => 'required|integer',
'alt' => 'nullable|numeric',
'batt' => 'nullable|integer',
'vel' => 'nullable|integer',
'acc' => 'nullable|integer',
]);
$activeJob = app(JobLifecycleService::class)->findActiveJob($request->user());
if ($activeJob === null) {
return response()->json([], 200);
}
GpsPoint::create([
'user_id' => $request->user()->id,
'job_id' => $activeJob->id,
'lat' => $validated['lat'],
'lon' => $validated['lon'],
'timestamp' => $validated['tst'],
'altitude' => $validated['alt'] ?? null,
'battery' => $validated['batt'] ?? null,
'velocity' => $validated['vel'] ?? null,
'accuracy' => $validated['acc'] ?? null,
]);
return response()->json([], 200);
}
}

View file

@ -0,0 +1,47 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\LoginRequest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
class AuthenticatedSessionController extends Controller
{
/**
* Display the login view.
*/
public function create(): View
{
return view('auth.login');
}
/**
* Handle an incoming authentication request.
*/
public function store(LoginRequest $request): RedirectResponse
{
$request->authenticate();
$request->session()->regenerate();
return redirect()->intended(route('dashboard', absolute: false));
}
/**
* Destroy an authenticated session.
*/
public function destroy(Request $request): RedirectResponse
{
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect('/');
}
}

View file

@ -0,0 +1,40 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
class ConfirmablePasswordController extends Controller
{
/**
* Show the confirm password view.
*/
public function show(): View
{
return view('auth.confirm-password');
}
/**
* Confirm the user's password.
*/
public function store(Request $request): RedirectResponse
{
if (! Auth::guard('web')->validate([
'email' => $request->user()->email,
'password' => $request->password,
])) {
throw ValidationException::withMessages([
'password' => __('auth.password'),
]);
}
$request->session()->put('auth.password_confirmed_at', time());
return redirect()->intended(route('dashboard', absolute: false));
}
}

View file

@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class EmailVerificationNotificationController extends Controller
{
/**
* Send a new email verification notification.
*/
public function store(Request $request): RedirectResponse
{
if ($request->user()->hasVerifiedEmail()) {
return redirect()->intended(route('dashboard', absolute: false));
}
$request->user()->sendEmailVerificationNotification();
return back()->with('status', 'verification-link-sent');
}
}

View file

@ -0,0 +1,21 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class EmailVerificationPromptController extends Controller
{
/**
* Display the email verification prompt.
*/
public function __invoke(Request $request): RedirectResponse|View
{
return $request->user()->hasVerifiedEmail()
? redirect()->intended(route('dashboard', absolute: false))
: view('auth.verify-email');
}
}

View file

@ -0,0 +1,63 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
class NewPasswordController extends Controller
{
/**
* Display the password reset view.
*/
public function create(Request $request): View
{
return view('auth.reset-password', ['request' => $request]);
}
/**
* Handle an incoming new password request.
*
* @throws ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'token' => ['required'],
'email' => ['required', 'email'],
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
// Here we will attempt to reset the user's password. If it is successful we
// will update the password on an actual user model and persist it to the
// database. Otherwise we will parse the error and return the response.
$status = Password::reset(
$request->only('email', 'password', 'password_confirmation', 'token'),
function (User $user) use ($request) {
$user->forceFill([
'password' => Hash::make($request->password),
'remember_token' => Str::random(60),
])->save();
event(new PasswordReset($user));
}
);
// If the password was successfully reset, we will redirect the user back to
// the application's home authenticated view. If there is an error we can
// redirect them back to where they came from with their error message.
return $status == Password::PASSWORD_RESET
? redirect()->route('login')->with('status', __($status))
: back()->withInput($request->only('email'))
->withErrors(['email' => __($status)]);
}
}

View file

@ -0,0 +1,29 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
class PasswordController extends Controller
{
/**
* Update the user's password.
*/
public function update(Request $request): RedirectResponse
{
$validated = $request->validateWithBag('updatePassword', [
'current_password' => ['required', 'current_password'],
'password' => ['required', Password::defaults(), 'confirmed'],
]);
$request->user()->update([
'password' => Hash::make($validated['password']),
]);
return back()->with('status', 'password-updated');
}
}

View file

@ -0,0 +1,45 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Password;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
class PasswordResetLinkController extends Controller
{
/**
* Display the password reset link request view.
*/
public function create(): View
{
return view('auth.forgot-password');
}
/**
* Handle an incoming password reset link request.
*
* @throws ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'email' => ['required', 'email'],
]);
// We will send the password reset link to this user. Once we have attempted
// to send the link, we will examine the response then see the message we
// need to show to the user. Finally, we'll send out a proper response.
$status = Password::sendResetLink(
$request->only('email')
);
return $status == Password::RESET_LINK_SENT
? back()->with('status', __($status))
: back()->withInput($request->only('email'))
->withErrors(['email' => __($status)]);
}
}

View file

@ -0,0 +1,51 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
class RegisteredUserController extends Controller
{
/**
* Display the registration view.
*/
public function create(): View
{
return view('auth.register');
}
/**
* Handle an incoming registration request.
*
* @throws ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
]);
event(new Registered($user));
Auth::login($user);
return redirect(route('dashboard', absolute: false));
}
}

View file

@ -0,0 +1,27 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Auth\Events\Verified;
use Illuminate\Foundation\Auth\EmailVerificationRequest;
use Illuminate\Http\RedirectResponse;
class VerifyEmailController extends Controller
{
/**
* Mark the authenticated user's email address as verified.
*/
public function __invoke(EmailVerificationRequest $request): RedirectResponse
{
if ($request->user()->hasVerifiedEmail()) {
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
}
if ($request->user()->markEmailAsVerified()) {
event(new Verified($request->user()));
}
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
}
}

View file

@ -0,0 +1,10 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
abstract class Controller
{
use AuthorizesRequests;
}

View file

@ -0,0 +1,98 @@
<?php
namespace App\Http\Controllers\Driver;
use App\Enums\JobType;
use App\Exceptions\JobLifecycleException;
use App\Http\Controllers\Controller;
use App\Models\CustomerObject;
use App\Models\Vehicle;
use App\Services\JobLifecycleService;
use App\Services\PhotoService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\Rule;
class DriverJobController extends Controller
{
public function start(Request $request): RedirectResponse
{
$validated = $request->validate([
'customer_object_id' => ['required', 'exists:customer_objects,id'],
'type' => ['required', Rule::enum(JobType::class)],
'vehicle_id' => ['nullable', 'exists:vehicles,id'],
]);
$customerObject = CustomerObject::findOrFail($validated['customer_object_id']);
$vehicle = isset($validated['vehicle_id']) ? Vehicle::find($validated['vehicle_id']) : null;
try {
app(JobLifecycleService::class)->startJob(
$request->user(),
$customerObject,
JobType::from($validated['type']),
$vehicle,
);
} catch (JobLifecycleException $e) {
return redirect()->back()->with('error', $e->getMessage());
}
return redirect()->back()->with('success', __('job.started'));
}
public function end(Request $request): RedirectResponse
{
$validated = $request->validate([
'notes' => ['nullable', 'string', 'max:1000'],
]);
try {
app(JobLifecycleService::class)->endJob(
$request->user(),
$validated['notes'] ?? null,
);
} catch (JobLifecycleException $e) {
return redirect()->back()->with('error', $e->getMessage());
}
return redirect()->back()->with('success', __('job.ended'));
}
public function active(Request $request): JsonResponse
{
$service = app(JobLifecycleService::class);
$user = $request->user();
$shift = $service->findActiveShift($user);
$job = $shift ? $service->findActiveJob($user) : null;
if ($job) {
$job->loadCount('gpsPoints')->load(['customerObject.customer', 'vehicle', 'jobPhotos']);
}
return response()->json([
'shift' => $shift ? [
'id' => $shift->id,
'started_at' => $shift->started_at->toIso8601String(),
] : null,
'job' => $job ? [
'id' => $job->id,
'customer_name' => $job->customerObject?->customer?->name ?? '',
'object_name' => $job->customerObject?->name,
'type_label' => $job->type->label(),
'vehicle_label' => $job->vehicle?->displayLabel(),
'started_at' => $job->started_at->toIso8601String(),
'gps_points_count' => $job->gps_points_count,
'photos_remaining' => PhotoService::MAX_PHOTOS_PER_JOB - $job->jobPhotos->count(),
'photos' => $job->jobPhotos->map(fn ($p) => [
'id' => $p->id,
'thumbnail_url' => Storage::disk('public')->url($p->thumbnail_path),
'full_url' => Storage::disk('public')->url($p->file_path),
'caption' => $p->caption,
]),
] : null,
]);
}
}

View file

@ -0,0 +1,32 @@
<?php
namespace App\Http\Controllers\Driver;
use App\Http\Controllers\Controller;
use App\Models\Job;
use Illuminate\Http\Request;
use Illuminate\View\View;
class DriverJobHistoryController extends Controller
{
public function index(Request $request): View
{
$jobs = Job::where('user_id', $request->user()->id)
->with(['customer', 'customerObject.customer'])
->withCount('jobPhotos')
->orderByDesc('started_at')
->paginate(20);
return view('driver.jobs.index', compact('jobs'));
}
public function show(Request $request, Job $job): View
{
abort_unless($job->user_id === $request->user()->id, 403);
$job->load(['customer', 'customerObject.customer', 'vehicle', 'weatherSnapshots', 'jobPhotos'])
->loadCount('gpsPoints');
return view('driver.jobs.show', compact('job'));
}
}

View file

@ -0,0 +1,45 @@
<?php
namespace App\Http\Controllers\Driver;
use App\Http\Controllers\Controller;
use App\Services\JobLifecycleService;
use App\Services\PhotoService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class DriverPhotoController extends Controller
{
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'photo' => ['required', 'image', 'mimes:jpeg,png,heic,webp', 'max:10240'],
'caption' => ['nullable', 'string', 'max:255'],
]);
$job = app(JobLifecycleService::class)->findActiveJob($request->user());
if (! $job) {
return response()->json(['message' => __('job.no_active_job')], 422);
}
if (! PhotoService::canAddPhoto($job)) {
return response()->json([
'message' => __('job.photo_limit_reached', ['max' => PhotoService::MAX_PHOTOS_PER_JOB]),
], 422);
}
$photo = app(PhotoService::class)->store($validated['photo'], $job);
if (! empty($validated['caption'])) {
$photo->update(['caption' => $validated['caption']]);
}
return response()->json([
'id' => $photo->id,
'thumbnail_url' => Storage::disk('public')->url($photo->thumbnail_path),
'photos_remaining' => PhotoService::MAX_PHOTOS_PER_JOB - $job->jobPhotos()->count(),
], 201);
}
}

View file

@ -0,0 +1,34 @@
<?php
namespace App\Http\Controllers\Driver;
use App\Exceptions\JobLifecycleException;
use App\Http\Controllers\Controller;
use App\Services\JobLifecycleService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class DriverShiftController extends Controller
{
public function start(Request $request): RedirectResponse
{
try {
app(JobLifecycleService::class)->startShift($request->user());
} catch (JobLifecycleException $e) {
return redirect()->back()->with('error', $e->getMessage());
}
return redirect()->back()->with('success', __('workshift.started'));
}
public function end(Request $request): RedirectResponse
{
try {
app(JobLifecycleService::class)->endShift($request->user());
} catch (JobLifecycleException $e) {
return redirect()->back()->with('error', $e->getMessage());
}
return redirect()->back()->with('success', __('workshift.ended'));
}
}

View file

@ -0,0 +1,51 @@
<?php
namespace App\Http\Controllers\Driver;
use App\Enums\JobType;
use App\Http\Controllers\Controller;
use App\Http\Requests\Driver\StoreManualJobRequest;
use App\Models\Customer;
use App\Models\CustomerObject;
use App\Models\Vehicle;
use App\Services\JobLifecycleService;
use Carbon\Carbon;
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;
class ManualJobController extends Controller
{
public function __construct(
private readonly JobLifecycleService $service,
) {}
public function create(): View
{
return view('driver.jobs.manual.create', [
'customers' => Customer::with('objects')->orderBy('name')->get(),
'vehicles' => Vehicle::all(),
'jobTypes' => JobType::cases(),
]);
}
public function store(StoreManualJobRequest $request): RedirectResponse
{
$validated = $request->validated();
$customerObject = CustomerObject::findOrFail($validated['customer_object_id']);
$vehicle = isset($validated['vehicle_id']) ? Vehicle::find($validated['vehicle_id']) : null;
$this->service->createManualJob(
driver: $request->user(),
customerObject: $customerObject,
type: JobType::from($validated['type']),
startedAt: Carbon::parse($validated['started_at']),
endedAt: Carbon::parse($validated['ended_at']),
notes: $validated['notes'] ?? null,
vehicle: $vehicle,
);
return redirect()->route('driver.job.manual.create')
->with('success', __('job.manual_created'));
}
}

View file

@ -0,0 +1,97 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\ConfirmDsgvoRequest;
use App\Models\DsgvoConfirmation;
use App\Models\Setting;
use Illuminate\Support\Str;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\Response;
class DsgvoOnboardingController extends Controller
{
public function show(): View
{
[$text, $version] = $this->currentTemplate();
$text = $this->replacePlaceholders($text);
$dsgvoHtml = Str::markdown($text, ['html_input' => 'strip']);
$companyDataMissing = empty(Setting::get('company_name'));
return view('onboarding.dsgvo', [
'dsgvoHtml' => $dsgvoHtml,
'templateVersion' => $version,
'companyDataMissing' => $companyDataMissing,
]);
}
public function confirm(ConfirmDsgvoRequest $request): Response
{
if (empty(Setting::get('company_name'))) {
return redirect()->route('onboarding.dsgvo')
->with('error', __('dsgvo.company_data_missing_title'));
}
[$text, $version] = $this->currentTemplate();
DsgvoConfirmation::create([
'driver_id' => $request->user()->id,
'confirmed_at' => now(),
'signed_by' => $request->validated('signed_by'),
'notice_text_snapshot' => $text,
'notice_language' => 'de',
'template_version' => $version,
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
]);
$user = $request->user();
$user->dsgvo_informed_at = now();
$user->confirmed_version = $version;
$user->save();
return redirect()->route('dashboard')
->with('success', __('dsgvo.flash_confirmed'));
}
/**
* @return array{0: string, 1: int}
*/
private function currentTemplate(): array
{
$text = Setting::get('dsgvo_template_markdown');
$version = (int) Setting::get('dsgvo_template_version', 1);
if ($text === null) {
$text = view('dsgvo.default-template')->render();
}
return [$text, $version];
}
private function replacePlaceholders(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", ', ');
$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

@ -0,0 +1,411 @@
<?php
namespace App\Http\Controllers;
use App\Enums\UserRole;
use App\Models\Setting;
use App\Models\User;
use App\Services\Installer\EnvFileWriter;
use App\Services\Installer\InstallLockManager;
use App\Services\Installer\MigrationRunner;
use App\Services\Installer\PreflightChecker;
use App\Services\Installer\StorageConfigurator;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Mail;
use Illuminate\View\View;
use PDO;
class InstallerController extends Controller
{
public function __construct(
private EnvFileWriter $envWriter,
private PreflightChecker $preflightChecker,
private MigrationRunner $migrationRunner,
private StorageConfigurator $storageConfigurator,
private InstallLockManager $lockManager,
) {}
// --- Locale switcher (works on any installer step) ---
public function switchLocale(Request $request, string $locale): RedirectResponse
{
if (in_array($locale, ['de', 'en'], true)) {
$request->session()->put('installer_locale', $locale);
}
return redirect($request->headers->get('referer') ?: route('install.welcome'));
}
// --- Step 1: Welcome ---
public function showWelcome(Request $request): View
{
$this->autoDetectAppUrl($request);
return view('installer.step1-welcome', ['currentStep' => 1]);
}
public function processWelcome(): RedirectResponse
{
return redirect()->route('install.preflight');
}
// --- Step 2: Preflight (was Step 3) ---
// --- Step 3: Database (was Step 2) ---
public function showDatabase(): View
{
return view('installer.step2-database', [
'currentStep' => 3,
'env_content' => null,
]);
}
public function storeDatabase(Request $request): RedirectResponse
{
$validated = $request->validate([
'db_host' => 'required|string',
'db_port' => 'required|integer|min:1|max:65535',
'db_database' => 'required|string',
'db_username' => 'required|string',
'db_password' => 'nullable|string',
]);
$password = $validated['db_password'] ?? '';
try {
new PDO(
"mysql:host={$validated['db_host']};port={$validated['db_port']};dbname={$validated['db_database']}",
$validated['db_username'],
$password,
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_TIMEOUT => 5]
);
} catch (\PDOException $e) {
return redirect()->route('install.database')
->withInput()
->withErrors(['db_connection' => __('install.error_db_connection') . ' (' . $e->getMessage() . ')']);
}
$envValues = [
'DB_CONNECTION' => 'mysql',
'DB_HOST' => $validated['db_host'],
'DB_PORT' => (string) $validated['db_port'],
'DB_DATABASE' => $validated['db_database'],
'DB_USERNAME' => $validated['db_username'],
'DB_PASSWORD' => $password,
];
if (! $this->envWriter->isWritable()) {
$this->envWriter->setMany($envValues);
Artisan::call('config:clear');
if (! $this->envWriter->isWritable()) {
return redirect()->route('install.database')
->withInput()
->with('env_content', $this->envWriter->getFullContent())
->withErrors(['env_write' => __('install.error_env_write')]);
}
}
$this->envWriter->setMany($envValues);
Artisan::call('config:clear');
return redirect()->route('install.migrations');
}
public function showPreflight(): View
{
return view('installer.step3-preflight', [
'currentStep' => 2,
'checks' => $this->preflightChecker->check(),
'hasCritical' => $this->preflightChecker->hasCriticalFailures(),
]);
}
public function processPreflight(): RedirectResponse
{
if ($this->preflightChecker->hasCriticalFailures()) {
return redirect()->route('install.preflight')
->withErrors(['preflight' => 'Kritische Voraussetzungen nicht erfüllt.']);
}
return redirect()->route('install.database');
}
// --- Step 4: Migrations ---
public function showMigrations(): View
{
return view('installer.step4-migrations', ['currentStep' => 4]);
}
public function runMigrations(): RedirectResponse
{
$result = $this->migrationRunner->run();
if (! $result['success']) {
return redirect()->route('install.migrations')
->withErrors(['migration' => __('install.error_migration_main')])
->with('migration_output', $result['error'] ?? $result['output']);
}
$this->envWriter->setMany([
'SESSION_DRIVER' => 'database',
'CACHE_STORE' => 'database',
]);
Artisan::call('config:clear');
return redirect()->route('install.config');
}
// --- Step 5: Config ---
public function showConfig(Request $request): View
{
$detectedUrl = $request->schemeAndHttpHost();
return view('installer.step5-config', [
'currentStep' => 5,
'app_url' => $this->envWriter->get('APP_URL') ?: $detectedUrl,
'timezone' => $this->envWriter->get('APP_DISPLAY_TIMEZONE') ?: 'Europe/Berlin',
]);
}
public function storeConfig(Request $request): RedirectResponse
{
$validated = $request->validate([
'app_url' => 'required|url',
'timezone' => 'required|string|timezone:all',
'locale' => 'required|string|in:de,en',
]);
$this->envWriter->setMany([
'APP_URL' => $validated['app_url'],
'APP_DISPLAY_TIMEZONE' => $validated['timezone'],
'APP_LOCALE' => $validated['locale'],
]);
Artisan::call('config:clear');
$brand = $validated['locale'] === 'de' ? 'schneespur' : 'wintertrace';
try {
Setting::set('app_url', $validated['app_url']);
Setting::set('display_timezone', $validated['timezone']);
Setting::set('locale', $validated['locale']);
Setting::set('app_brand', $brand);
} catch (\Exception) {
// Settings table may not exist yet in edge cases — .env is the primary store
}
return redirect()->route('install.storage');
}
// --- Step 6: Storage ---
public function showStorage(): View
{
return view('installer.step6-storage', [
'currentStep' => 6,
'results' => null,
]);
}
public function runStorage(): View
{
$results = $this->storageConfigurator->runAll();
return view('installer.step6-storage', [
'currentStep' => 6,
'results' => $results,
]);
}
// --- Step 7: Admin ---
public function showAdmin(): View
{
return view('installer.step7-admin', ['currentStep' => 7]);
}
public function storeAdmin(Request $request): RedirectResponse
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'email' => 'required|email|max:255',
'password' => 'required|string|min:8|confirmed',
]);
User::create([
'name' => $validated['name'],
'email' => $validated['email'],
'password' => $validated['password'],
])->forceFill(['role' => UserRole::Admin])->save();
$this->lockManager->lock();
try {
Setting::set('installed_at', now()->toIso8601String());
} catch (\Exception) {
// Fallback: lock file is the authoritative indicator
}
Artisan::call('config:clear');
return redirect()->route('install.mail');
}
// --- Step 8: Mail ---
public function showMail(): View
{
return view('installer.step8-mail', ['currentStep' => 8]);
}
public function sendTestMail(Request $request): RedirectResponse
{
$validated = $request->validate([
'mail_host' => 'required|string',
'mail_port' => 'required|integer',
'mail_username' => 'nullable|string',
'mail_password' => 'nullable|string',
'mail_encryption' => 'nullable|string|in:tls,ssl,null',
'mail_from_address' => 'required|email',
'mail_from_name' => 'required|string',
'test_recipient' => 'required|email',
]);
$schemeMap = ['tls' => '', 'ssl' => 'smtps', 'null' => ''];
$mailScheme = $schemeMap[$validated['mail_encryption'] ?? 'null'] ?? '';
$this->envWriter->setMany([
'MAIL_MAILER' => 'smtp',
'MAIL_HOST' => $validated['mail_host'],
'MAIL_PORT' => (string) $validated['mail_port'],
'MAIL_SCHEME' => $mailScheme,
'MAIL_USERNAME' => $validated['mail_username'] ?? '',
'MAIL_PASSWORD' => $validated['mail_password'] ?? '',
'MAIL_FROM_ADDRESS' => $validated['mail_from_address'],
'MAIL_FROM_NAME' => $validated['mail_from_name'],
]);
Artisan::call('config:clear');
config([
'mail.default' => 'smtp',
'mail.mailers.smtp.host' => $validated['mail_host'],
'mail.mailers.smtp.port' => $validated['mail_port'],
'mail.mailers.smtp.username' => $validated['mail_username'],
'mail.mailers.smtp.password' => $validated['mail_password'],
'mail.mailers.smtp.scheme' => $mailScheme ?: null,
'mail.from.address' => $validated['mail_from_address'],
'mail.from.name' => $validated['mail_from_name'],
]);
try {
Mail::raw(__('install.mail_test_body', ['brand' => brand()]), function ($message) use ($validated) {
$message->to($validated['test_recipient'])
->subject(brand() . ' — ' . __('install.mail_test_subject'));
});
return redirect()->route('install.cron')
->with('flash_test_mail', __('install.flash_test_mail', ['email' => $validated['test_recipient']]));
} catch (\Exception $e) {
return redirect()->route('install.mail')
->withInput()
->withErrors(['mail' => $e->getMessage()]);
}
}
public function skipMail(): RedirectResponse
{
return redirect()->route('install.cron');
}
// --- Step 9: Cron ---
public function showCron(): View
{
$cronLine = '* * * * * ' . $this->detectPhpCli() . ' ' . base_path('artisan') . ' schedule:run >> /dev/null 2>&1';
$cronActive = cache()->has('cron.last_run');
return view('installer.step9-cron', [
'currentStep' => 9,
'cronLine' => $cronLine,
'cronActive' => $cronActive,
]);
}
public function testCron(): RedirectResponse
{
try {
Artisan::call('schedule:run');
} catch (\Exception) {
// Not critical
}
cache()->put('cron.last_run', now());
return redirect()->route('install.cron')
->with('cron_test_success', true);
}
public function skipCron(): RedirectResponse
{
return redirect()->route('install.done');
}
// --- Helpers ---
private function detectPhpCli(): string
{
$binary = PHP_BINARY;
if (str_contains($binary, 'fpm') || str_contains($binary, 'cgi')) {
$version = PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION;
foreach (["/usr/bin/php{$version}", "/usr/bin/php", "/usr/local/bin/php"] as $candidate) {
if (is_executable($candidate)) {
return $candidate;
}
}
}
return $binary;
}
private function autoDetectAppUrl(Request $request): void
{
$detected = $request->getSchemeAndHttpHost();
$current = $this->envWriter->get('APP_URL');
if (! $current || $current === 'http://localhost') {
$this->envWriter->set('APP_URL', $detected);
config(['app.url' => $detected]);
url()->forceRootUrl($detected);
if ($request->isSecure()) {
url()->forceScheme('https');
}
}
}
// --- Done ---
public function showDone(): View
{
if (! $this->lockManager->isLocked()) {
return view('installer.step1-welcome', ['currentStep' => 1]);
}
$admin = User::where('role', UserRole::Admin)->first();
return view('installer.done', [
'currentStep' => 10,
'appUrl' => $this->envWriter->get('APP_URL') ?: url('/'),
'adminEmail' => $admin?->email ?? '—',
'mailConfigured' => ! empty($this->envWriter->get('MAIL_HOST')),
]);
}
}

View file

@ -0,0 +1,77 @@
<?php
namespace App\Http\Controllers\Portal;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
class PortalAuthController extends Controller
{
public function showLogin(): View
{
return view('portal.auth.login');
}
public function login(Request $request): RedirectResponse
{
$request->validate([
'email' => ['required', 'email'],
'password' => ['required', 'string'],
]);
$throttleKey = Str::transliterate(Str::lower($request->input('email')).'|'.$request->ip());
if (RateLimiter::tooManyAttempts($throttleKey, 5)) {
$seconds = RateLimiter::availableIn($throttleKey);
throw ValidationException::withMessages([
'email' => __('auth.throttle', ['seconds' => $seconds]),
]);
}
if (! Auth::guard('customer')->attempt(
$request->only('email', 'password'),
$request->boolean('remember')
)) {
RateLimiter::hit($throttleKey);
throw ValidationException::withMessages([
'email' => __('portal.invalid_credentials'),
]);
}
$customer = Auth::guard('customer')->user();
if (! $customer->portal_enabled) {
Auth::guard('customer')->logout();
RateLimiter::hit($throttleKey);
throw ValidationException::withMessages([
'email' => __('portal.account_disabled'),
]);
}
RateLimiter::clear($throttleKey);
$request->session()->regenerate();
return redirect()->intended(route('portal.home'));
}
public function logout(Request $request): RedirectResponse
{
Auth::guard('customer')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('portal.login');
}
}

View file

@ -0,0 +1,48 @@
<?php
namespace App\Http\Controllers\Portal;
use App\Http\Controllers\Controller;
use App\Models\Job;
use App\Services\SeasonService;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View;
class PortalDashboardController extends Controller
{
public function __invoke(SeasonService $seasonService): View
{
$customer = auth('customer')->user();
$season = $seasonService->currentOrLastSeason();
$totalJobs = Job::where('customer_id', $customer->id)
->whereNotNull('ended_at')
->whereBetween('started_at', [$season->start, $season->end])
->count();
$isSqlite = DB::getDriverName() === 'sqlite';
$durationExpr = $isSqlite
? "SUM((JULIANDAY(COALESCE(ended_at, started_at)) - JULIANDAY(started_at)) * 1440)"
: "SUM(TIMESTAMPDIFF(MINUTE, started_at, COALESCE(ended_at, started_at)))";
$totalMinutes = (int) Job::selectRaw("{$durationExpr} as total_minutes")
->where('customer_id', $customer->id)
->whereNotNull('ended_at')
->whereBetween('started_at', [$season->start, $season->end])
->value('total_minutes');
$totalHours = number_format($totalMinutes / 60, 1);
$lastJob = Job::where('customer_id', $customer->id)
->whereNotNull('ended_at')
->latest('started_at')
->first();
$objects = $customer->objects()
->withMax(['serviceJobs as last_job_at' => fn ($q) => $q->whereNotNull('ended_at')], 'started_at')
->orderBy('name')
->get();
return view('portal.home', compact('season', 'totalJobs', 'totalHours', 'lastJob', 'objects'));
}
}

View file

@ -0,0 +1,74 @@
<?php
namespace App\Http\Controllers\Portal;
use App\Enums\JobType;
use App\Http\Controllers\Controller;
use App\Models\Job;
use App\Services\GpsSmoothingService;
use Illuminate\Http\Request;
use Illuminate\View\View;
class PortalJobController extends Controller
{
public function index(Request $request): View
{
$customer = auth('customer')->user();
$jobs = Job::where('customer_id', $customer->id)
->whereNotNull('ended_at')
->with(['customerObject'])
->when($customer->portal_show_driver_name, fn ($q) => $q->with('user'))
->when($request->customer_object_id, fn ($q, $id) => $q->where('customer_object_id', $id))
->when($request->started_after, fn ($q, $date) => $q->where('started_at', '>=', $date))
->when($request->started_before, fn ($q, $date) => $q->where('started_at', '<=', $date))
->when($request->type, fn ($q, $type) => $q->where('type', $type))
->orderByDesc('started_at')
->paginate(25)
->withQueryString();
$objects = $customer->objects()->orderBy('name')->get();
$jobTypes = JobType::cases();
return view('portal.jobs.index', compact('jobs', 'objects', 'jobTypes', 'customer'));
}
public function show(Job $serviceJob, GpsSmoothingService $gpsSmoother): View
{
$customer = auth('customer')->user();
abort_unless($serviceJob->customer_id === $customer->id, 404);
$relations = ['customerObject', 'weatherSnapshots'];
if ($customer->portal_show_photos) {
$relations['jobPhotos'] = fn ($q) => $q->orderBy('sort_order')->orderBy('created_at');
}
if ($customer->portal_show_gps) {
$relations['gpsPoints'] = fn ($q) => $q->orderBy('timestamp');
}
if ($customer->portal_show_driver_name) {
$relations[] = 'user';
}
$serviceJob->load($relations);
$smoothedGps = collect();
if ($customer->portal_show_gps && $serviceJob->gpsPoints->isNotEmpty()) {
$smoothedGps = $gpsSmoother->smooth($serviceJob->gpsPoints)
->map(fn ($p) => ['lat' => $p->lat, 'lon' => $p->lon]);
}
$driverLastName = null;
if ($customer->portal_show_driver_name && $serviceJob->user) {
$parts = explode(' ', trim($serviceJob->user->name));
$driverLastName = end($parts);
}
return view('portal.jobs.show', [
'job' => $serviceJob,
'customer' => $customer,
'smoothedGps' => $smoothedGps,
'driverLastName' => $driverLastName,
]);
}
}

View file

@ -0,0 +1,33 @@
<?php
namespace App\Http\Controllers\Portal;
use App\Http\Controllers\Controller;
use App\Models\Customer;
use App\Models\Job;
use App\Models\NotificationLog;
use Illuminate\View\View;
class PortalNotificationController extends Controller
{
public function index(): View
{
/** @var Customer $customer */
$customer = auth('customer')->user();
$logs = NotificationLog::query()
->where(function ($query) use ($customer) {
$query->where(function ($q) use ($customer) {
$q->where('notifiable_type', Job::class)
->whereIn('notifiable_id', $customer->serviceJobs()->select('id'));
})->orWhere(function ($q) use ($customer) {
$q->where('notifiable_type', Customer::class)
->where('notifiable_id', $customer->id);
});
})
->orderByDesc('created_at')
->paginate(20);
return view('portal.notifications.index', compact('logs'));
}
}

View file

@ -0,0 +1,97 @@
<?php
namespace App\Http\Controllers\Portal;
use App\Http\Controllers\Controller;
use App\Models\CustomerObject;
use App\Models\Job;
use App\Services\PdfReportService;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\Response;
class PortalPdfController extends Controller
{
public function __construct(
private readonly PdfReportService $pdfReportService,
) {}
public function jobPdf(Job $serviceJob): Response
{
$customer = auth('customer')->user();
abort_unless($serviceJob->customer_id === $customer->id, 404);
abort_unless($serviceJob->ended_at !== null, 422, __('portal.reports_job_not_completed'));
$pdf = $this->pdfReportService->generateJobReport($serviceJob);
$filename = $this->pdfReportService->jobReportFilename($serviceJob);
return $pdf->download($filename);
}
public function index(): View
{
$customer = auth('customer')->user();
$objects = $customer->objects()->orderBy('name')->get();
return view('portal.reports.index', [
'objects' => $objects,
'defaultFrom' => Carbon::now()->startOfMonth()->format('Y-m-d'),
'defaultTo' => Carbon::now()->format('Y-m-d'),
]);
}
public function generate(Request $request): Response
{
$customer = auth('customer')->user();
$validated = $request->validate([
'from' => ['required', 'date'],
'to' => ['required', 'date', 'after_or_equal:from'],
'customer_object_id' => ['nullable', 'integer'],
]);
$from = Carbon::parse($validated['from']);
$to = Carbon::parse($validated['to']);
$objectId = $validated['customer_object_id'] ?? null;
if ($objectId) {
$object = CustomerObject::where('id', $objectId)
->where('customer_id', $customer->id)
->first();
abort_unless($object !== null, 404);
$jobCount = Job::where('customer_object_id', $object->id)
->whereNotNull('ended_at')
->where('started_at', '>=', $from)
->where('started_at', '<=', $to->copy()->endOfDay())
->count();
if ($jobCount === 0) {
return redirect()->back()
->withInput()
->with('error', __('portal.reports_no_jobs'));
}
$pdf = $this->pdfReportService->generateObjectReport($object, $from, $to);
$filename = $this->pdfReportService->objectReportFilename($object, $from, $to);
} else {
$jobCount = Job::where('customer_id', $customer->id)
->whereNotNull('ended_at')
->where('started_at', '>=', $from)
->where('started_at', '<=', $to->copy()->endOfDay())
->count();
if ($jobCount === 0) {
return redirect()->back()
->withInput()
->with('error', __('portal.reports_no_jobs'));
}
$pdf = $this->pdfReportService->generateCustomerReport($customer, $from, $to);
$filename = $this->pdfReportService->customerReportFilename($customer, $from, $to);
}
return $pdf->download($filename);
}
}

View file

@ -0,0 +1,76 @@
<?php
namespace App\Http\Controllers\Portal;
use App\Http\Controllers\Controller;
use App\Mail\CustomerEmailChangedMail;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Password;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
class PortalProfileController extends Controller
{
public function edit(Request $request): View
{
return view('portal.profile.edit', [
'customer' => auth('customer')->user(),
]);
}
public function update(Request $request): RedirectResponse
{
$customer = auth('customer')->user();
$throttleKey = 'portal-profile|' . $customer->id;
if (RateLimiter::tooManyAttempts($throttleKey, 10)) {
$seconds = RateLimiter::availableIn($throttleKey);
throw ValidationException::withMessages([
'email' => __('auth.throttle', ['seconds' => $seconds]),
]);
}
RateLimiter::hit($throttleKey, 3600);
$validated = $request->validate([
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', Rule::unique('customers')->ignore($customer->id)],
'locale' => ['required', 'in:de,en'],
'current_password' => ['nullable', 'required_with:password', 'current_password:customer'],
'password' => ['nullable', 'confirmed', Password::defaults()],
]);
$customer->email = $validated['email'];
$customer->locale = $validated['locale'];
$emailChanged = $customer->isDirty('email');
$oldEmail = $customer->getOriginal('email');
if ($request->filled('password')) {
$customer->password = $validated['password'];
}
$customer->save();
RateLimiter::clear($throttleKey);
if ($emailChanged) {
$admin = User::first();
if ($admin) {
Mail::to($admin)->send(new CustomerEmailChangedMail($customer, $oldEmail, $validated['email']));
}
}
App::setLocale($customer->locale);
session(['locale' => $customer->locale]);
return redirect()->back()->with('success', __('portal.profile_saved'));
}
}

View file

@ -0,0 +1,60 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\ProfileUpdateRequest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Redirect;
use Illuminate\View\View;
class ProfileController extends Controller
{
/**
* Display the user's profile form.
*/
public function edit(Request $request): View
{
return view('profile.edit', [
'user' => $request->user(),
]);
}
/**
* Update the user's profile information.
*/
public function update(ProfileUpdateRequest $request): RedirectResponse
{
$request->user()->fill($request->validated());
if ($request->user()->isDirty('email')) {
$request->user()->email_verified_at = null;
}
$request->user()->save();
return Redirect::route('profile.edit')->with('status', 'profile-updated');
}
/**
* Delete the user's account.
*/
public function destroy(Request $request): RedirectResponse
{
$request->validateWithBag('userDeletion', [
'password' => ['required', 'current_password'],
]);
$user = $request->user();
Auth::logout();
$user->delete();
$request->session()->invalidate();
$request->session()->regenerateToken();
return Redirect::to('/');
}
}

View file

@ -0,0 +1,27 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\Response;
class StorageFallbackController extends Controller
{
public function __invoke(string $path): Response
{
$disk = Storage::disk('public');
if (! $disk->exists($path)) {
abort(404);
}
$mimeType = $disk->mimeType($path) ?: 'application/octet-stream';
$lastModified = $disk->lastModified($path);
return response($disk->get($path), 200, [
'Content-Type' => $mimeType,
'Cache-Control' => 'public, max-age=604800',
'Last-Modified' => gmdate('D, d M Y H:i:s', $lastModified) . ' GMT',
]);
}
}

View file

@ -0,0 +1,32 @@
<?php
namespace App\Http\Middleware;
use App\Models\User;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Symfony\Component\HttpFoundation\Response;
class AuthenticateOwntracks
{
public function handle(Request $request, Closure $next): Response
{
$username = $request->getUser();
$password = $request->getPassword();
if ($username === null || $password === null) {
return response()->json(['error' => 'Unauthorized'], 401);
}
$user = User::where('owntracks_username', $username)->first();
if (! $user || ! Hash::check($password, $user->owntracks_password_hash)) {
return response()->json(['error' => 'Unauthorized'], 401);
}
$request->setUserResolver(fn () => $user);
return $next($request);
}
}

View file

@ -0,0 +1,19 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureAdmin
{
public function handle(Request $request, Closure $next): Response
{
if (! $request->user()?->isAdmin()) {
return redirect()->route('dashboard');
}
return $next($request);
}
}

Some files were not shown because too many files have changed in this diff Show more