diff --git a/schneespur/VERSION b/schneespur/VERSION index 6d7de6e..21e8796 100644 --- a/schneespur/VERSION +++ b/schneespur/VERSION @@ -1 +1 @@ -1.0.2 +1.0.3 diff --git a/schneespur/app/Http/Controllers/Admin/AdminModuleController.php b/schneespur/app/Http/Controllers/Admin/AdminModuleController.php index fac9abc..ea6518d 100644 --- a/schneespur/app/Http/Controllers/Admin/AdminModuleController.php +++ b/schneespur/app/Http/Controllers/Admin/AdminModuleController.php @@ -23,12 +23,7 @@ class AdminModuleController extends Controller try { $catalog = $client->fetchCatalog(); - if ($catalog !== null) { - $catalogModules = $catalog['modules'] ?? []; - } else { - $state = $client->loadState(); - $catalogModules = $state['installed'] ?? []; - } + $catalogModules = $catalog['modules'] ?? []; } catch (\Throwable $e) { Log::warning('schneespur-modules: catalog fetch failed in admin UI', [ 'error' => $e->getMessage(), diff --git a/schneespur/app/Http/Requests/Admin/StoreCustomerObjectRequest.php b/schneespur/app/Http/Requests/Admin/StoreCustomerObjectRequest.php index fbf706c..1520e12 100644 --- a/schneespur/app/Http/Requests/Admin/StoreCustomerObjectRequest.php +++ b/schneespur/app/Http/Requests/Admin/StoreCustomerObjectRequest.php @@ -19,6 +19,14 @@ class StoreCustomerObjectRequest extends FormRequest 'price_amount' => str_replace(',', '.', $this->price_amount), ]); } + + // notify_recipients is a mode enum consumed as customer|object|both + // (see SendJobCompletedNotification / SendCustomerReportEmail). + // Fall back to the DB default when the field is missing or empty + // so the NOT NULL column never receives a null value. + if (! $this->filled('notify_recipients')) { + $this->merge(['notify_recipients' => 'customer']); + } } /** @@ -44,7 +52,7 @@ class StoreCustomerObjectRequest extends FormRequest 'lon' => ['nullable', 'numeric', 'between:-180,180'], 'auto_notify_email' => ['boolean'], 'notification_email' => ['nullable', 'required_if:auto_notify_email,1', 'email', 'max:200'], - 'notify_recipients' => ['nullable', 'string', 'max:1000'], + 'notify_recipients' => ['required', 'in:customer,object,both'], ]; } diff --git a/schneespur/app/Services/SchneespurModuleClient.php b/schneespur/app/Services/SchneespurModuleClient.php index a2415d9..ee84ac9 100644 --- a/schneespur/app/Services/SchneespurModuleClient.php +++ b/schneespur/app/Services/SchneespurModuleClient.php @@ -28,19 +28,24 @@ class SchneespurModuleClient /** * Fetch the module catalog from the server. * - * Returns parsed catalog array on 200, null on 304 (not modified). - * Throws on HTTP error. + * Returns normalized catalog array. On 304 returns the cached normalized + * catalog from state (or re-fetches without If-None-Match if no cache). + * Returns null only when there is no cache and the server-side state + * cannot be reconstructed. Throws on HTTP error. */ public function fetchCatalog(): ?array { - $state = $this->loadState(); - $etag = $state['catalog_etag'] ?? null; + $state = $this->loadState(); + $etag = $state['catalog_etag'] ?? null; + $cached = $state['catalog_cache'] ?? null; $url = $this->serverUrl . str_replace('{slug}', $this->collectionSlug, $this->catalogEndpoint); $request = Http::acceptJson()->timeout($this->timeout); - if ($etag) { + // Only send If-None-Match when we actually have a cached body to fall + // back on — otherwise a 304 leaves us with nothing to display. + if ($etag && $cached !== null) { $request = $request->withHeaders(['If-None-Match' => $etag]); } @@ -51,7 +56,7 @@ class SchneespurModuleClient $state['synced_at'] = now()->toIso8601String(); $this->writeState($state); - return null; + return $cached; } if ($response->status() === 404) { @@ -69,14 +74,20 @@ class SchneespurModuleClient throw new RuntimeException("Katalog-Fetch fehlgeschlagen: HTTP {$response->status()}"); } - $catalog = $response->json(); - if (! is_array($catalog) || ! array_key_exists('modules', $catalog)) { + $raw = $response->json(); + if (! is_array($raw) || ! array_key_exists('modules', $raw)) { throw new RuntimeException('Katalog-Response hat unerwartete Form'); } + $catalog = [ + 'collection' => $raw['collection'] ?? null, + 'modules' => array_map(fn ($m) => $this->normalizeModule($m), $raw['modules']), + ]; + $newEtag = $response->header('ETag'); - $state['catalog_etag'] = $newEtag ?: ($state['catalog_etag'] ?? null); - $state['synced_at'] = now()->toIso8601String(); + $state['catalog_etag'] = $newEtag ?: ($state['catalog_etag'] ?? null); + $state['catalog_cache'] = $catalog; + $state['synced_at'] = now()->toIso8601String(); $this->writeState($state); $moduleCount = count($catalog['modules']); @@ -85,6 +96,36 @@ class SchneespurModuleClient return $catalog; } + /** + * Map a server-side module entry to the internal shape expected by the + * admin controller and views. The server may evolve its field names + * independently — this is the single place where that drift is bridged. + */ + private function normalizeModule(array $raw): array + { + $appLocale = app()->getLocale(); + $primary = $raw['primary_locale'] ?? $appLocale; + + $category = $raw['category'] ?? null; + if (is_array($category)) { + $category = self::i18nPick($category, $primary); + } + + return [ + 'slug' => $raw['slug'] ?? null, + 'name' => $raw['name'] ?? [], + 'description' => $raw['description'] ?? [], + 'version' => $raw['current_version'] ?? $raw['version'] ?? null, + 'category' => $category, + 'image' => $raw['image_url'] ?? $raw['image'] ?? null, + 'download_url' => $raw['download_url'] ?? null, + 'sha256' => $raw['sha256'] ?? null, + 'size_bytes' => $raw['size_bytes'] ?? null, + 'requires_permissions' => $raw['requires_permissions'] ?? [], + 'primary_locale' => $primary, + ]; + } + /** * Download a module ZIP, verify size and SHA256. * @@ -175,10 +216,11 @@ class SchneespurModuleClient { if (! is_file($this->stateFilePath)) { return [ - 'catalog_etag' => null, - 'synced_at' => null, - 'installed' => [], - 'orphans' => [], + 'catalog_etag' => null, + 'catalog_cache' => null, + 'synced_at' => null, + 'installed' => [], + 'orphans' => [], ]; } @@ -193,10 +235,11 @@ class SchneespurModuleClient } return [ - 'catalog_etag' => $parsed['catalog_etag'] ?? null, - 'synced_at' => $parsed['synced_at'] ?? null, - 'installed' => $parsed['installed'] ?? [], - 'orphans' => $parsed['orphans'] ?? [], + 'catalog_etag' => $parsed['catalog_etag'] ?? null, + 'catalog_cache' => $parsed['catalog_cache'] ?? null, + 'synced_at' => $parsed['synced_at'] ?? null, + 'installed' => $parsed['installed'] ?? [], + 'orphans' => $parsed['orphans'] ?? [], ]; } diff --git a/schneespur/app/Services/SchneespurModuleInstaller.php b/schneespur/app/Services/SchneespurModuleInstaller.php index 6b47e6a..79cbe1b 100644 --- a/schneespur/app/Services/SchneespurModuleInstaller.php +++ b/schneespur/app/Services/SchneespurModuleInstaller.php @@ -124,7 +124,52 @@ class SchneespurModuleInstaller } File::ensureDirectoryExists($targetDir, 0755); - $zip->extractTo($targetDir); + + $prefix = $this->detectCommonPrefix($zip); + + if ($prefix === null) { + $zip->extractTo($targetDir); + } else { + Log::info('schneespur-modules: stripping common prefix', [ + 'slug' => $slug, + 'prefix' => $prefix, + ]); + + for ($i = 0; $i < $zip->numFiles; $i++) { + $entry = $zip->getNameIndex($i); + if ($entry === false || $entry === '' || $entry === $prefix) { + continue; + } + if (str_starts_with($entry, '__MACOSX/')) { + continue; + } + + $relative = substr($entry, strlen($prefix)); + if ($relative === '') { + continue; + } + + $dest = $targetDir . '/' . $relative; + + if (str_ends_with($entry, '/')) { + File::ensureDirectoryExists($dest, 0755); + continue; + } + + File::ensureDirectoryExists(dirname($dest), 0755); + + $contents = $zip->getFromIndex($i); + if ($contents === false || file_put_contents($dest, $contents) === false) { + Log::error('schneespur-modules: extract failed', [ + 'slug' => $slug, + 'entry' => $entry, + ]); + $zip->close(); + return false; + } + } + } + $zip->close(); Log::info('schneespur-modules: unpack complete', ['slug' => $slug]); @@ -132,6 +177,41 @@ class SchneespurModuleInstaller return true; } + /** + * Detect whether all (non-metadata) ZIP entries share one common + * top-level folder. Returns the prefix (incl. trailing slash) when so, + * null when the ZIP is flat or has mixed top-level entries. + */ + private function detectCommonPrefix(ZipArchive $zip): ?string + { + $prefix = null; + + for ($i = 0; $i < $zip->numFiles; $i++) { + $entry = $zip->getNameIndex($i); + if ($entry === false || $entry === '') { + continue; + } + if (str_starts_with($entry, '__MACOSX/')) { + continue; + } + + $slash = strpos($entry, '/'); + if ($slash === false) { + return null; + } + + $top = substr($entry, 0, $slash + 1); + + if ($prefix === null) { + $prefix = $top; + } elseif ($prefix !== $top) { + return null; + } + } + + return $prefix; + } + private function validateZipEntries(ZipArchive $zip, string $slug): bool { for ($i = 0; $i < $zip->numFiles; $i++) { diff --git a/schneespur/bootstrap/app.php b/schneespur/bootstrap/app.php index e28436d..196f325 100644 --- a/schneespur/bootstrap/app.php +++ b/schneespur/bootstrap/app.php @@ -73,15 +73,17 @@ return Application::configure(basePath: dirname(__DIR__)) }) ->withExceptions(function (Exceptions $exceptions): void { $exceptions->reportable(function (\Throwable $e) { + $reported = false; try { $manager = app(DiagnosticManager::class); if ($manager->hasEnabledReporters()) { $manager->reportException($e); + $reported = true; } } catch (\Throwable) { // Never let diagnostic reporting break the application } - return false; + return $reported ? false : null; }); })->create(); diff --git a/schneespur/config/app.php b/schneespur/config/app.php index fdbfe8c..964a199 100644 --- a/schneespur/config/app.php +++ b/schneespur/config/app.php @@ -15,7 +15,7 @@ return [ 'name' => env('APP_NAME', 'Schneespur'), - 'version' => '1.0.0', + 'version' => trim((string) @file_get_contents(dirname(__DIR__).'/VERSION')) ?: '1.0.0', /* |-------------------------------------------------------------------------- diff --git a/schneespur/lang/de/customer_object.php b/schneespur/lang/de/customer_object.php index 9fb4523..e544801 100644 --- a/schneespur/lang/de/customer_object.php +++ b/schneespur/lang/de/customer_object.php @@ -31,7 +31,10 @@ return [ 'field_access_notes' => 'Zufahrt / Zugang', 'field_auto_notify' => 'Automatische Benachrichtigung per E-Mail', 'field_notification_email' => 'Benachrichtigungs-E-Mail', - 'field_notify_recipients' => 'Weitere Empfänger (kommagetrennt)', + 'field_notify_recipients' => 'Benachrichtigungsempfänger', + 'notify_recipients_customer' => 'Nur Kunde', + 'notify_recipients_object' => 'Nur Objekt-Kontakt', + 'notify_recipients_both' => 'Kunde und Objekt-Kontakt', 'price_unit_per_job' => 'Pro Einsatz', 'price_unit_monthly' => 'Monatlich', 'price_unit_seasonal' => 'Saisonal', diff --git a/schneespur/lang/en/customer_object.php b/schneespur/lang/en/customer_object.php index f9edb41..38cc366 100644 --- a/schneespur/lang/en/customer_object.php +++ b/schneespur/lang/en/customer_object.php @@ -31,7 +31,10 @@ return [ 'field_access_notes' => 'Access / Entrance', 'field_auto_notify' => 'Automatic email notification', 'field_notification_email' => 'Notification Email', - 'field_notify_recipients' => 'Additional recipients (comma-separated)', + 'field_notify_recipients' => 'Notification recipients', + 'notify_recipients_customer' => 'Customer only', + 'notify_recipients_object' => 'Object contact only', + 'notify_recipients_both' => 'Customer and object contact', 'price_unit_per_job' => 'Per Job', 'price_unit_monthly' => 'Monthly', 'price_unit_seasonal' => 'Seasonal', diff --git a/schneespur/resources/views/admin/customer_objects/_form.blade.php b/schneespur/resources/views/admin/customer_objects/_form.blade.php index e4d33e2..abd8305 100644 --- a/schneespur/resources/views/admin/customer_objects/_form.blade.php +++ b/schneespur/resources/views/admin/customer_objects/_form.blade.php @@ -206,7 +206,12 @@
- + @php($notifyRecipients = old('notify_recipients', $object->notify_recipients ?? 'customer')) +