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:
parent
2c63440ed8
commit
7288b93500
8107 changed files with 1085684 additions and 9 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
1.0.1
|
1.0.2
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\Module;
|
use App\Models\Module;
|
||||||
|
use App\Services\ModuleManager;
|
||||||
use App\Services\SchneespurModuleClient;
|
use App\Services\SchneespurModuleClient;
|
||||||
use App\Services\SchneespurModuleInstaller;
|
use App\Services\SchneespurModuleInstaller;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
|
@ -58,6 +59,7 @@ class AdminModuleController extends Controller
|
||||||
'download_url' => $catModule['download_url'] ?? null,
|
'download_url' => $catModule['download_url'] ?? null,
|
||||||
'sha256' => $catModule['sha256'] ?? null,
|
'sha256' => $catModule['sha256'] ?? null,
|
||||||
'size_bytes' => $catModule['size_bytes'] ?? null,
|
'size_bytes' => $catModule['size_bytes'] ?? null,
|
||||||
|
'requires_permissions' => $catModule['requires_permissions'] ?? [],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -80,6 +82,7 @@ class AdminModuleController extends Controller
|
||||||
'download_url' => null,
|
'download_url' => null,
|
||||||
'sha256' => null,
|
'sha256' => null,
|
||||||
'size_bytes' => null,
|
'size_bytes' => null,
|
||||||
|
'requires_permissions' => $this->resolveLocalPermissions($slug),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -230,6 +233,16 @@ class AdminModuleController extends Controller
|
||||||
->with('success', __('modules.disabled', ['slug' => $slug]));
|
->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
|
public function remove(string $slug, SchneespurModuleInstaller $installer): RedirectResponse
|
||||||
{
|
{
|
||||||
$module = Module::where('slug', $slug)->first();
|
$module = Module::where('slug', $slug)->first();
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,9 @@ use App\Models\User;
|
||||||
use App\Models\Vehicle;
|
use App\Models\Vehicle;
|
||||||
use App\Policies\JobPolicy;
|
use App\Policies\JobPolicy;
|
||||||
use App\Services\AlertService;
|
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\DashboardWidgetRegistry;
|
||||||
use App\Services\Extension\NavigationRegistry;
|
use App\Services\Extension\NavigationRegistry;
|
||||||
use App\Services\ForecastService;
|
use App\Services\ForecastService;
|
||||||
|
|
@ -44,6 +47,9 @@ class AppServiceProvider extends ServiceProvider
|
||||||
$this->app->singleton(AlertService::class);
|
$this->app->singleton(AlertService::class);
|
||||||
$this->app->singleton(DashboardWidgetRegistry::class);
|
$this->app->singleton(DashboardWidgetRegistry::class);
|
||||||
$this->app->singleton(NavigationRegistry::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(ModuleManager::class, fn ($app) => new ModuleManager($app));
|
||||||
$this->app->singleton(WeatherProviderRegistry::class, function ($app) {
|
$this->app->singleton(WeatherProviderRegistry::class, function ($app) {
|
||||||
$registry = new WeatherProviderRegistry($app);
|
$registry = new WeatherProviderRegistry($app);
|
||||||
|
|
|
||||||
83
app/Services/Diagnostic/DiagnosticManager.php
Normal file
83
app/Services/Diagnostic/DiagnosticManager.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
210
app/Services/Diagnostic/DiagnosticPayloadSanitizer.php
Normal file
210
app/Services/Diagnostic/DiagnosticPayloadSanitizer.php
Normal 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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
22
app/Services/Diagnostic/DiagnosticReporterInterface.php
Normal file
22
app/Services/Diagnostic/DiagnosticReporterInterface.php
Normal 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;
|
||||||
|
}
|
||||||
51
app/Services/Diagnostic/DiagnosticReporterRegistry.php
Normal file
51
app/Services/Diagnostic/DiagnosticReporterRegistry.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Services\Diagnostic\DiagnosticManager;
|
||||||
use Illuminate\Contracts\Foundation\Application;
|
use Illuminate\Contracts\Foundation\Application;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
@ -138,6 +139,7 @@ class ModuleManager
|
||||||
'trace' => $e->getTraceAsString(),
|
'trace' => $e->getTraceAsString(),
|
||||||
]);
|
]);
|
||||||
$this->autoDisable($slug, $e->getMessage());
|
$this->autoDisable($slug, $e->getMessage());
|
||||||
|
$this->reportDiagnostic('module_boot_failed', $slug, $e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -188,6 +190,13 @@ class ModuleManager
|
||||||
return $this->disabledModules;
|
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
|
protected function autoDisable(string $slug, string $reason): void
|
||||||
{
|
{
|
||||||
if (! in_array($slug, $this->disabledModules, true)) {
|
if (! in_array($slug, $this->disabledModules, true)) {
|
||||||
|
|
@ -199,4 +208,20 @@ class ModuleManager
|
||||||
'reason' => $reason,
|
'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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Services\Diagnostic\DiagnosticManager;
|
||||||
use Illuminate\Support\Facades\File;
|
use Illuminate\Support\Facades\File;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
|
|
@ -28,7 +29,13 @@ class SchneespurModuleInstaller
|
||||||
return false;
|
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
|
public function update(string $zipPath, string $slug): bool
|
||||||
|
|
@ -56,6 +63,7 @@ class SchneespurModuleInstaller
|
||||||
}
|
}
|
||||||
|
|
||||||
Log::error('schneespur-modules: update failed, triggering rollback', ['slug' => $slug]);
|
Log::error('schneespur-modules: update failed, triggering rollback', ['slug' => $slug]);
|
||||||
|
$this->reportDiagnostic('module_update_failed', $slug, 'ZIP extraction failed, rollback triggered');
|
||||||
$this->rollback($slug);
|
$this->rollback($slug);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -161,4 +169,15 @@ class SchneespurModuleInstaller
|
||||||
{
|
{
|
||||||
return $this->modulesPath . '/' . $slug . '.bak';
|
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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ use App\Http\Middleware\EnsureDsgvoInformed;
|
||||||
use App\Http\Middleware\InstallerGuard;
|
use App\Http\Middleware\InstallerGuard;
|
||||||
use App\Http\Middleware\RedirectToInstaller;
|
use App\Http\Middleware\RedirectToInstaller;
|
||||||
use App\Http\Middleware\SetInstallerLocale;
|
use App\Http\Middleware\SetInstallerLocale;
|
||||||
|
use App\Services\Diagnostic\DiagnosticManager;
|
||||||
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
|
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
|
||||||
use Illuminate\Cookie\Middleware\EncryptCookies;
|
use Illuminate\Cookie\Middleware\EncryptCookies;
|
||||||
use Illuminate\Console\Scheduling\Schedule;
|
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();
|
$schedule->call(fn () => cache()->put('cron.last_run', now()))->everyMinute();
|
||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions): void {
|
->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();
|
})->create();
|
||||||
|
|
|
||||||
|
|
@ -45,4 +45,6 @@ return [
|
||||||
'update_failed' => 'Aktualisierung von ":slug" fehlgeschlagen: :error',
|
'update_failed' => 'Aktualisierung von ":slug" fehlgeschlagen: :error',
|
||||||
'directory_exists' => 'Modulverzeichnis existiert bereits.',
|
'directory_exists' => 'Modulverzeichnis existiert bereits.',
|
||||||
'extraction_failed' => 'ZIP-Entpacken fehlgeschlagen.',
|
'extraction_failed' => 'ZIP-Entpacken fehlgeschlagen.',
|
||||||
|
|
||||||
|
'permission_tooltip' => 'Dieses Modul benötigt diese Berechtigung.',
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -45,4 +45,6 @@ return [
|
||||||
'update_failed' => 'Update of ":slug" failed: :error',
|
'update_failed' => 'Update of ":slug" failed: :error',
|
||||||
'directory_exists' => 'Module directory already exists.',
|
'directory_exists' => 'Module directory already exists.',
|
||||||
'extraction_failed' => 'ZIP extraction failed.',
|
'extraction_failed' => 'ZIP extraction failed.',
|
||||||
|
|
||||||
|
'permission_tooltip' => 'This module requires this permission.',
|
||||||
];
|
];
|
||||||
|
|
|
||||||
1006
moduldoku.md
Normal file
1006
moduldoku.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -4,5 +4,6 @@
|
||||||
"namespace": "Schneespur\\Module\\Example",
|
"namespace": "Schneespur\\Module\\Example",
|
||||||
"service_provider": "Schneespur\\Module\\Example\\ExampleServiceProvider",
|
"service_provider": "Schneespur\\Module\\Example\\ExampleServiceProvider",
|
||||||
"description": "Reference module demonstrating all extension points (nav, widget, event, settings, route).",
|
"description": "Reference module demonstrating all extension points (nav, widget, event, settings, route).",
|
||||||
"min_schneespur_version": "1.0.0"
|
"min_schneespur_version": "1.0.0",
|
||||||
|
"requires_permissions": []
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1
public/build/assets/app-DTM5xC6O.css
Normal file
1
public/build/assets/app-DTM5xC6O.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -12,7 +12,7 @@
|
||||||
"src": "node_modules/leaflet/dist/images/marker-icon.png"
|
"src": "node_modules/leaflet/dist/images/marker-icon.png"
|
||||||
},
|
},
|
||||||
"resources/css/app.css": {
|
"resources/css/app.css": {
|
||||||
"file": "assets/app-frBbeKiu.css",
|
"file": "assets/app-DTM5xC6O.css",
|
||||||
"src": "resources/css/app.css",
|
"src": "resources/css/app.css",
|
||||||
"isEntry": true,
|
"isEntry": true,
|
||||||
"name": "app",
|
"name": "app",
|
||||||
|
|
|
||||||
|
|
@ -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} didn’t 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} didn’t 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")});
|
||||||
|
|
|
||||||
BIN
release/schneespur-1.0.2.zip
Normal file
BIN
release/schneespur-1.0.2.zip
Normal file
Binary file not shown.
18
release/schneespur-1.0.2/.editorconfig
Normal file
18
release/schneespur-1.0.2/.editorconfig
Normal 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
|
||||||
54
release/schneespur-1.0.2/.env.example
Normal file
54
release/schneespur-1.0.2/.env.example
Normal 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}"
|
||||||
295
release/schneespur-1.0.2/INSTALL.de.md
Normal file
295
release/schneespur-1.0.2/INSTALL.de.md
Normal 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.
|
||||||
295
release/schneespur-1.0.2/INSTALL.en.md
Normal file
295
release/schneespur-1.0.2/INSTALL.en.md
Normal 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.
|
||||||
661
release/schneespur-1.0.2/LICENSE
Normal file
661
release/schneespur-1.0.2/LICENSE
Normal 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/>.
|
||||||
138
release/schneespur-1.0.2/README.md
Normal file
138
release/schneespur-1.0.2/README.md
Normal 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 · Wetterdaten · Fotos · rechtsfester Einsatznachweis
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://schneespur.de">schneespur.de</a> ·
|
||||||
|
<a href="https://wintertrace.com">wintertrace.com</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="#english">English</a> ·
|
||||||
|
<a href="INSTALL.de.md">Installation (DE)</a> ·
|
||||||
|
<a href="INSTALL.en.md">Installation (EN)</a> ·
|
||||||
|
<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).
|
||||||
1
release/schneespur-1.0.2/VERSION
Normal file
1
release/schneespur-1.0.2/VERSION
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
1.0.2
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
184
release/schneespur-1.0.2/app/Console/Commands/ModulesSync.php
Normal file
184
release/schneespur-1.0.2/app/Console/Commands/ModulesSync.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
16
release/schneespur-1.0.2/app/Enums/JobType.php
Normal file
16
release/schneespur-1.0.2/app/Enums/JobType.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
release/schneespur-1.0.2/app/Enums/UserRole.php
Normal file
18
release/schneespur-1.0.2/app/Enums/UserRole.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
release/schneespur-1.0.2/app/Enums/WeatherMoment.php
Normal file
14
release/schneespur-1.0.2/app/Enums/WeatherMoment.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
16
release/schneespur-1.0.2/app/Events/CustomerCreated.php
Normal file
16
release/schneespur-1.0.2/app/Events/CustomerCreated.php
Normal 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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
18
release/schneespur-1.0.2/app/Events/JobCompleted.php
Normal file
18
release/schneespur-1.0.2/app/Events/JobCompleted.php
Normal 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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
16
release/schneespur-1.0.2/app/Events/JobStarted.php
Normal file
16
release/schneespur-1.0.2/app/Events/JobStarted.php
Normal 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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
@ -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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 . '"',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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'),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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'),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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']]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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('/');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
10
release/schneespur-1.0.2/app/Http/Controllers/Controller.php
Normal file
10
release/schneespur-1.0.2/app/Http/Controllers/Controller.php
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
|
|
||||||
|
abstract class Controller
|
||||||
|
{
|
||||||
|
use AuthorizesRequests;
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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')),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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('/');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
release/schneespur-1.0.2/app/Http/Middleware/EnsureAdmin.php
Normal file
19
release/schneespur-1.0.2/app/Http/Middleware/EnsureAdmin.php
Normal 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
Loading…
Reference in a new issue