diff --git a/app/Filament/Blog/Resources/CategoryResource.php b/app/Filament/Blog/Resources/CategoryResource.php index b6d59d0..7696687 100644 --- a/app/Filament/Blog/Resources/CategoryResource.php +++ b/app/Filament/Blog/Resources/CategoryResource.php @@ -44,6 +44,11 @@ class CategoryResource extends Resource protected static ?string $modelLabel = 'Kategorie'; + public static function getNavigationGroup(): string + { + return 'Content & Bibliothek'; + } + public static function form(Schema $schema): Schema { return $schema diff --git a/app/Filament/Blog/Resources/PostResource.php b/app/Filament/Blog/Resources/PostResource.php index e7b3292..fc2da4d 100644 --- a/app/Filament/Blog/Resources/PostResource.php +++ b/app/Filament/Blog/Resources/PostResource.php @@ -51,6 +51,11 @@ class PostResource extends Resource protected static ?string $modelLabel = 'Beitrag'; + public static function getNavigationGroup(): string + { + return 'Content & Bibliothek'; + } + public static function form(Schema $schema): Schema { return $schema diff --git a/app/Filament/Resources/EmotionResource.php b/app/Filament/Resources/EmotionResource.php index 2f1f745..023e9bb 100644 --- a/app/Filament/Resources/EmotionResource.php +++ b/app/Filament/Resources/EmotionResource.php @@ -28,7 +28,7 @@ class EmotionResource extends Resource public static function getNavigationGroup(): UnitEnum|string|null { - return __('admin.nav.library'); + return __('admin.nav.tasks_emotions'); } protected static ?int $navigationSort = 10; diff --git a/app/Filament/Resources/EventPurchaseResource.php b/app/Filament/Resources/EventPurchaseResource.php index b1c79cd..ddc290d 100644 --- a/app/Filament/Resources/EventPurchaseResource.php +++ b/app/Filament/Resources/EventPurchaseResource.php @@ -37,6 +37,11 @@ class EventPurchaseResource extends Resource return false; } + public static function getNavigationGroup(): string + { + return 'Billing & Finanzen'; + } + public static function form(Schema $schema): Schema { return $schema diff --git a/app/Filament/Resources/EventResource.php b/app/Filament/Resources/EventResource.php index 41c9935..6023ddf 100644 --- a/app/Filament/Resources/EventResource.php +++ b/app/Filament/Resources/EventResource.php @@ -34,7 +34,7 @@ class EventResource extends Resource public static function getNavigationGroup(): UnitEnum|string|null { - return __('admin.nav.platform'); + return __('admin.nav.event_management'); } public static function form(Schema $form): Schema diff --git a/app/Filament/Resources/EventTypeResource.php b/app/Filament/Resources/EventTypeResource.php index dc33c79..1deadee 100644 --- a/app/Filament/Resources/EventTypeResource.php +++ b/app/Filament/Resources/EventTypeResource.php @@ -25,7 +25,7 @@ class EventTypeResource extends Resource public static function getNavigationGroup(): UnitEnum|string|null { - return __('admin.nav.library'); + return __('admin.nav.event_management'); } protected static ?int $navigationSort = 20; diff --git a/app/Filament/Resources/LegalPageResource.php b/app/Filament/Resources/LegalPageResource.php index c88e03f..d3ad897 100644 --- a/app/Filament/Resources/LegalPageResource.php +++ b/app/Filament/Resources/LegalPageResource.php @@ -27,7 +27,7 @@ class LegalPageResource extends Resource public static function getNavigationGroup(): UnitEnum|string|null { - return __('admin.nav.platform'); + return __('admin.nav.content_library'); } protected static ?int $navigationSort = 40; diff --git a/app/Filament/Resources/MediaStorageTargetResource.php b/app/Filament/Resources/MediaStorageTargetResource.php new file mode 100644 index 0000000..cf76414 --- /dev/null +++ b/app/Filament/Resources/MediaStorageTargetResource.php @@ -0,0 +1,130 @@ +schema([ + TextInput::make('key') + ->label('Schlüssel') + ->required() + ->unique(ignoreRecord: true) + ->maxLength(64), + TextInput::make('name') + ->label('Bezeichnung') + ->required() + ->maxLength(255), + Select::make('driver') + ->label('Treiber') + ->required() + ->options([ + 'local' => 'Local', + 'sftp' => 'SFTP', + 's3' => 'S3 Compatible', + ]), + TextInput::make('priority') + ->label('Priorität') + ->numeric() + ->default(0), + Toggle::make('is_hot') + ->label('Hot Storage') + ->helperText('Markiert Speicher als primär für aktive Uploads.') + ->default(false), + Toggle::make('is_default') + ->label('Standard') + ->helperText('Wird automatisch für neue Events verwendet.') + ->default(false), + Toggle::make('is_active') + ->label('Aktiv') + ->default(true), + KeyValue::make('config') + ->label('Konfiguration') + ->keyLabel('Option') + ->valueLabel('Wert') + ->columnSpanFull() + ->helperText('Treiber-spezifische Einstellungen wie Pfade, Hosts oder Zugangsdaten.'), + ])->columns(2); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('key') + ->label('Key') + ->searchable() + ->sortable(), + Tables\Columns\TextColumn::make('name') + ->label('Name') + ->searchable(), + Tables\Columns\BadgeColumn::make('driver') + ->label('Driver') + ->colors([ + 'gray' => 'local', + 'info' => 'sftp', + 'success' => 's3', + ]), + Tables\Columns\IconColumn::make('is_hot') + ->label('Hot') + ->boolean(), + Tables\Columns\IconColumn::make('is_default') + ->label('Default') + ->boolean(), + Tables\Columns\IconColumn::make('is_active') + ->label('Active') + ->boolean(), + Tables\Columns\TextColumn::make('priority') + ->label('Priority') + ->sortable(), + Tables\Columns\TextColumn::make('updated_at') + ->label('Aktualisiert') + ->since() + ->sortable(), + ]) + ->filters([]) + ->actions([ + Actions\EditAction::make(), + ]) + ->bulkActions([ + Actions\DeleteBulkAction::make(), + ]); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListMediaStorageTargets::route('/'), + 'create' => Pages\CreateMediaStorageTarget::route('/create'), + 'edit' => Pages\EditMediaStorageTarget::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Resources/MediaStorageTargetResource/Pages/CreateMediaStorageTarget.php b/app/Filament/Resources/MediaStorageTargetResource/Pages/CreateMediaStorageTarget.php new file mode 100644 index 0000000..96c1fc9 --- /dev/null +++ b/app/Filament/Resources/MediaStorageTargetResource/Pages/CreateMediaStorageTarget.php @@ -0,0 +1,12 @@ + 'Basis-Uploads', + 'limited_sharing' => 'Begrenztes Teilen', 'unlimited_sharing' => 'Unbegrenztes Teilen', 'no_watermark' => 'Kein Wasserzeichen', 'custom_branding' => 'Eigenes Branding', 'custom_tasks' => 'Eigene Aufgaben', + 'reseller_dashboard' => 'Reseller-Dashboard', 'advanced_analytics' => 'Erweiterte Analytics', 'advanced_reporting' => 'Erweiterte Reports', 'live_slideshow' => 'Live-Slideshow', diff --git a/app/Filament/Resources/PhotoResource.php b/app/Filament/Resources/PhotoResource.php index 37dd767..a4e8165 100644 --- a/app/Filament/Resources/PhotoResource.php +++ b/app/Filament/Resources/PhotoResource.php @@ -30,7 +30,7 @@ class PhotoResource extends Resource public static function getNavigationGroup(): UnitEnum|string|null { - return __('admin.nav.content'); + return __('admin.nav.event_management'); } public static function form(Schema $form): Schema diff --git a/app/Filament/Resources/PurchaseResource.php b/app/Filament/Resources/PurchaseResource.php index dc78536..d9c2511 100644 --- a/app/Filament/Resources/PurchaseResource.php +++ b/app/Filament/Resources/PurchaseResource.php @@ -36,7 +36,7 @@ class PurchaseResource extends Resource public static function getNavigationGroup(): string { - return 'Billing'; + return 'Billing & Finanzen'; } protected static ?int $navigationSort = 10; diff --git a/app/Filament/Resources/TaskResource.php b/app/Filament/Resources/TaskResource.php index a49e8e6..98e0eea 100644 --- a/app/Filament/Resources/TaskResource.php +++ b/app/Filament/Resources/TaskResource.php @@ -28,7 +28,7 @@ class TaskResource extends Resource public static function getNavigationGroup(): UnitEnum|string|null { - return __('admin.nav.library'); + return __('admin.nav.tasks_emotions'); } public static function getNavigationLabel(): string diff --git a/app/Filament/Resources/TenantPackageResource.php b/app/Filament/Resources/TenantPackageResource.php index 01a31f1..1ca6721 100644 --- a/app/Filament/Resources/TenantPackageResource.php +++ b/app/Filament/Resources/TenantPackageResource.php @@ -32,6 +32,11 @@ class TenantPackageResource extends Resource protected static ?string $slug = 'tenant-packages'; + public static function getNavigationGroup(): string + { + return 'Billing & Finanzen'; + } + public static function form(Schema $form): Schema { return $form diff --git a/app/Filament/Resources/TenantResource.php b/app/Filament/Resources/TenantResource.php index 755d672..814e475 100644 --- a/app/Filament/Resources/TenantResource.php +++ b/app/Filament/Resources/TenantResource.php @@ -32,7 +32,7 @@ class TenantResource extends Resource public static function getNavigationGroup(): UnitEnum|string|null { - return __('admin.nav.platform'); + return __('admin.nav.platform_management'); } protected static ?int $navigationSort = 10; diff --git a/app/Filament/Resources/UserResource.php b/app/Filament/Resources/UserResource.php index daa05d3..f3eedd8 100644 --- a/app/Filament/Resources/UserResource.php +++ b/app/Filament/Resources/UserResource.php @@ -30,6 +30,11 @@ class UserResource extends Resource protected static ?string $slug = 'users'; + public static function getNavigationGroup(): string + { + return 'Plattform-Verwaltung'; + } + public static function form(Schema $form): Schema { return $form diff --git a/app/Filament/Widgets/StorageCapacityWidget.php b/app/Filament/Widgets/StorageCapacityWidget.php new file mode 100644 index 0000000..208a7a2 --- /dev/null +++ b/app/Filament/Widgets/StorageCapacityWidget.php @@ -0,0 +1,76 @@ +map(function (MediaStorageTarget $target) use ($health) { + $stats = $health->getCapacity($target); + + if ($stats['status'] !== 'ok') { + return Card::make($target->name, 'Kapazität unbekannt') + ->description(match ($stats['status']) { + 'unavailable' => 'Monitoring nicht verfügbar', + 'unknown' => 'Monitor-Pfad nicht gesetzt', + 'error' => $stats['message'] ?? 'Fehler beim Auslesen', + default => 'Status unbekannt', + }) + ->descriptionIcon('heroicon-m-question-mark-circle') + ->color('warning'); + } + + $used = $this->formatBytes($stats['used']); + $total = $this->formatBytes($stats['total']); + $free = $this->formatBytes($stats['free']); + $percentageValue = $stats['percentage']; + $percent = $percentageValue !== null ? $percentageValue.' %' : '–'; + + $color = 'success'; + if ($percentageValue === null) { + $color = 'warning'; + } elseif ($percentageValue >= 80) { + $color = 'danger'; + } elseif ($percentageValue >= 60) { + $color = 'warning'; + } + + return Card::make($target->name, "$used / $total") + ->description("Frei: $free · Auslastung: $percent") + ->color($color) + ->extraAttributes([ + 'data-storage-disk' => $target->key, + ]); + }) + ->toArray(); + } + + private function formatBytes(?int $bytes): string + { + if ($bytes === null) { + return '–'; + } + + $units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; + $index = 0; + $value = (float) $bytes; + + while ($value >= 1024 && $index < count($units) - 1) { + $value /= 1024; + $index++; + } + + return round($value, 1).' '.$units[$index]; + } +} diff --git a/app/Http/Controllers/Api/EventPublicController.php b/app/Http/Controllers/Api/EventPublicController.php index c2ec774..715c3f1 100644 --- a/app/Http/Controllers/Api/EventPublicController.php +++ b/app/Http/Controllers/Api/EventPublicController.php @@ -13,13 +13,17 @@ use Illuminate\Support\Str; use Symfony\Component\HttpFoundation\Response; use App\Support\ImageHelper; use App\Services\EventJoinTokenService; +use App\Services\Storage\EventStorageManager; +use App\Models\Event; use App\Models\EventJoinToken; use Illuminate\Http\JsonResponse; class EventPublicController extends BaseController { - public function __construct(private readonly EventJoinTokenService $joinTokenService) - { + public function __construct( + private readonly EventJoinTokenService $joinTokenService, + private readonly EventStorageManager $eventStorageManager, + ) { } /** @@ -236,6 +240,7 @@ class EventPublicController extends BaseController [$event] = $result; $eventId = $event->id; + $eventModel = Event::with('storageAssignments.storageTarget')->findOrFail($eventId); // Approximate online guests as distinct recent uploaders in last 10 minutes. $tenMinutesAgo = CarbonImmutable::now()->subMinutes(10); @@ -585,16 +590,19 @@ class EventPublicController extends BaseController ]); $file = $validated['photo']; - $path = Storage::disk('public')->putFile("events/{$eventId}/photos", $file); - $url = Storage::url($path); + $disk = $this->eventStorageManager->getHotDiskForEvent($eventModel); + $path = Storage::disk($disk)->putFile("events/{$eventId}/photos", $file); + $url = $this->resolveDiskUrl($disk, $path); // Generate thumbnail (JPEG) under photos/thumbs $baseName = pathinfo($path, PATHINFO_FILENAME); $thumbRel = "events/{$eventId}/photos/thumbs/{$baseName}_thumb.jpg"; - $thumbPath = ImageHelper::makeThumbnailOnDisk('public', $path, $thumbRel, 640, 82); - $thumbUrl = $thumbPath ? Storage::url($thumbPath) : $url; + $thumbPath = ImageHelper::makeThumbnailOnDisk($disk, $path, $thumbRel, 640, 82); + $thumbUrl = $thumbPath + ? $this->resolveDiskUrl($disk, $thumbPath) + : $url; - $id = DB::table('photos')->insertGetId([ + $photoId = DB::table('photos')->insertGetId([ 'event_id' => $eventId, 'task_id' => $validated['task_id'] ?? null, 'guest_name' => $validated['guest_name'] ?? $deviceId, @@ -610,8 +618,38 @@ class EventPublicController extends BaseController 'updated_at' => now(), ]); + $asset = $this->eventStorageManager->recordAsset($eventModel, $disk, $path, [ + 'variant' => 'original', + 'mime_type' => $file->getClientMimeType(), + 'size_bytes' => $file->getSize(), + 'checksum' => hash_file('sha256', $file->getRealPath()), + 'status' => 'hot', + 'processed_at' => now(), + 'photo_id' => $photoId, + ]); + + if ($thumbPath) { + $this->eventStorageManager->recordAsset($eventModel, $disk, $thumbPath, [ + 'variant' => 'thumbnail', + 'mime_type' => 'image/jpeg', + 'status' => 'hot', + 'processed_at' => now(), + 'photo_id' => $photoId, + 'size_bytes' => Storage::disk($disk)->exists($thumbPath) + ? Storage::disk($disk)->size($thumbPath) + : null, + 'meta' => [ + 'source_variant_id' => $asset->id, + ], + ]); + } + + DB::table('photos') + ->where('id', $photoId) + ->update(['media_asset_id' => $asset->id]); + return response()->json([ - 'id' => $id, + 'id' => $photoId, 'file_path' => $url, 'thumbnail_path' => $thumbUrl, ], 201); @@ -876,3 +914,18 @@ class EventPublicController extends BaseController ->header('ETag', $etag); } } + + private function resolveDiskUrl(string $disk, string $path): string + { + try { + return Storage::disk($disk)->url($path); + } catch (\Throwable $e) { + Log::debug('Falling back to raw path for storage URL', [ + 'disk' => $disk, + 'path' => $path, + 'error' => $e->getMessage(), + ]); + + return $path; + } + } diff --git a/app/Http/Controllers/Api/Tenant/PhotoController.php b/app/Http/Controllers/Api/Tenant/PhotoController.php index 0977654..7fc6d63 100644 --- a/app/Http/Controllers/Api/Tenant/PhotoController.php +++ b/app/Http/Controllers/Api/Tenant/PhotoController.php @@ -8,6 +8,7 @@ use App\Http\Resources\Tenant\PhotoResource; use App\Models\Event; use App\Models\Photo; use App\Support\ImageHelper; +use App\Services\Storage\EventStorageManager; use Illuminate\Http\Request; use Illuminate\Http\JsonResponse; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; @@ -16,9 +17,14 @@ use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Validator; use Illuminate\Validation\ValidationException; use Illuminate\Support\Str; +use Illuminate\Support\Facades\Log; +use App\Models\EventMediaAsset; class PhotoController extends Controller { + public function __construct(private readonly EventStorageManager $eventStorageManager) + { + } /** * Display a listing of the event's photos. */ @@ -81,17 +87,21 @@ class PhotoController extends Controller ]); } + // Determine storage target + $event->load('storageAssignments.storageTarget'); + $disk = $this->eventStorageManager->getHotDiskForEvent($event); + // Generate unique filename $extension = $file->getClientOriginalExtension(); $filename = Str::uuid() . '.' . $extension; $path = "events/{$eventSlug}/photos/{$filename}"; // Store original file - Storage::disk('public')->put($path, file_get_contents($file->getRealPath())); + Storage::disk($disk)->put($path, file_get_contents($file->getRealPath())); // Generate thumbnail $thumbnailPath = "events/{$eventSlug}/thumbnails/{$filename}"; - $thumbnailRelative = ImageHelper::makeThumbnailOnDisk('public', $path, $thumbnailPath, 400); + $thumbnailRelative = ImageHelper::makeThumbnailOnDisk($disk, $path, $thumbnailPath, 400); if ($thumbnailRelative) { $thumbnailPath = $thumbnailRelative; } @@ -105,7 +115,7 @@ class PhotoController extends Controller 'size' => $file->getSize(), 'path' => $path, 'thumbnail_path' => $thumbnailPath, - 'width' => null, // To be filled by image processing + 'width' => null, // Filled below 'height' => null, 'status' => 'pending', // Requires moderation 'uploader_id' => null, @@ -113,8 +123,38 @@ class PhotoController extends Controller 'user_agent' => $request->userAgent(), ]); + // Record primary asset metadata + $checksum = hash_file('sha256', $file->getRealPath()); + $asset = $this->eventStorageManager->recordAsset($event, $disk, $path, [ + 'variant' => 'original', + 'mime_type' => $file->getMimeType(), + 'size_bytes' => $file->getSize(), + 'checksum' => $checksum, + 'status' => 'hot', + 'processed_at' => now(), + 'photo_id' => $photo->id, + ]); + + if ($thumbnailRelative) { + $this->eventStorageManager->recordAsset($event, $disk, $thumbnailRelative, [ + 'variant' => 'thumbnail', + 'mime_type' => 'image/jpeg', + 'status' => 'hot', + 'processed_at' => now(), + 'photo_id' => $photo->id, + 'size_bytes' => Storage::disk($disk)->exists($thumbnailRelative) + ? Storage::disk($disk)->size($thumbnailRelative) + : null, + 'meta' => [ + 'source_variant_id' => $asset->id, + ], + ]); + } + + $photo->update(['media_asset_id' => $asset->id]); + // Get image dimensions - list($width, $height) = getimagesize($file->getRealPath()); + [$width, $height] = getimagesize($file->getRealPath()); $photo->update(['width' => $width, 'height' => $height]); $photo->load('event')->loadCount('likes'); @@ -202,15 +242,33 @@ class PhotoController extends Controller return response()->json(['error' => 'Photo not found'], 404); } - // Delete from storage - Storage::disk('public')->delete([ - $photo->path, - $photo->thumbnail_path, - ]); + $assets = EventMediaAsset::where('photo_id', $photo->id)->get(); + + foreach ($assets as $asset) { + try { + Storage::disk($asset->disk)->delete($asset->path); + } catch (\Throwable $e) { + Log::warning('Failed to delete asset from storage', [ + 'asset_id' => $asset->id, + 'disk' => $asset->disk, + 'path' => $asset->path, + 'error' => $e->getMessage(), + ]); + } + } + + // Ensure legacy paths are removed if assets missing + if ($assets->isEmpty()) { + $fallbackDisk = $this->eventStorageManager->getHotDiskForEvent($event); + Storage::disk($fallbackDisk)->delete([$photo->path, $photo->thumbnail_path]); + } // Delete record and likes - DB::transaction(function () use ($photo) { + DB::transaction(function () use ($photo, $assets) { $photo->likes()->delete(); + if ($assets->isNotEmpty()) { + EventMediaAsset::whereIn('id', $assets->pluck('id'))->delete(); + } $photo->delete(); }); @@ -474,16 +532,19 @@ class PhotoController extends Controller return response()->json(['error' => 'Invalid event ID'], 400); } + $event->load('storageAssignments.storageTarget'); + $disk = $this->eventStorageManager->getHotDiskForEvent($event); + $file = $request->file('photo'); $filename = $request->filename; $path = "events/{$eventSlug}/photos/{$filename}"; // Store file - Storage::disk('public')->put($path, file_get_contents($file->getRealPath())); + Storage::disk($disk)->put($path, file_get_contents($file->getRealPath())); // Generate thumbnail $thumbnailPath = "events/{$eventSlug}/thumbnails/{$filename}"; - $thumbnailRelative = ImageHelper::makeThumbnailOnDisk('public', $path, $thumbnailPath, 400); + $thumbnailRelative = ImageHelper::makeThumbnailOnDisk($disk, $path, $thumbnailPath, 400); if ($thumbnailRelative) { $thumbnailPath = $thumbnailRelative; } @@ -502,10 +563,38 @@ class PhotoController extends Controller 'user_agent' => $request->userAgent(), ]); - // Get dimensions - list($width, $height) = getimagesize($file->getRealPath()); + [$width, $height] = getimagesize($file->getRealPath()); $photo->update(['width' => $width, 'height' => $height]); + $checksum = hash_file('sha256', $file->getRealPath()); + $asset = $this->eventStorageManager->recordAsset($event, $disk, $path, [ + 'variant' => 'original', + 'mime_type' => $file->getMimeType(), + 'size_bytes' => $file->getSize(), + 'checksum' => $checksum, + 'status' => 'hot', + 'processed_at' => now(), + 'photo_id' => $photo->id, + ]); + + if ($thumbnailRelative) { + $this->eventStorageManager->recordAsset($event, $disk, $thumbnailRelative, [ + 'variant' => 'thumbnail', + 'mime_type' => 'image/jpeg', + 'status' => 'hot', + 'processed_at' => now(), + 'photo_id' => $photo->id, + 'size_bytes' => Storage::disk($disk)->exists($thumbnailRelative) + ? Storage::disk($disk)->size($thumbnailRelative) + : null, + 'meta' => [ + 'source_variant_id' => $asset->id, + ], + ]); + } + + $photo->update(['media_asset_id' => $asset->id]); + return response()->json([ 'message' => 'Upload successful. Awaiting moderation.', 'photo_id' => $photo->id, diff --git a/app/Jobs/ArchiveEventMediaAssets.php b/app/Jobs/ArchiveEventMediaAssets.php new file mode 100644 index 0000000..9c07af2 --- /dev/null +++ b/app/Jobs/ArchiveEventMediaAssets.php @@ -0,0 +1,103 @@ +onQueue('media-storage'); + } + + public function handle(EventStorageManager $storageManager): void + { + $event = Event::with('storageAssignments.storageTarget')->find($this->eventId); + + if (! $event) { + Log::warning('Archive job aborted: event missing', ['event_id' => $this->eventId]); + return; + } + + $archiveDisk = $storageManager->getArchiveDiskForEvent($event); + + if (! $archiveDisk) { + Log::warning('Archive job aborted: no archive disk configured', ['event_id' => $event->id]); + return; + } + + $archiveAssignment = $storageManager->ensureAssignment($event, null, 'archive'); + $archiveTargetId = $archiveAssignment->media_storage_target_id; + + $assets = EventMediaAsset::where('event_id', $event->id) + ->whereIn('status', ['hot', 'pending', 'restoring']) + ->orderBy('id') + ->get(); + + foreach ($assets as $asset) { + $sourceDisk = $asset->disk; + + if ($sourceDisk === $archiveDisk && $asset->status === 'archived') { + continue; + } + + $archivePath = $asset->path; + + $stream = null; + + try { + $stream = Storage::disk($sourceDisk)->readStream($asset->path); + + if (! $stream) { + throw new \RuntimeException('Source stream is null'); + } + + Storage::disk($archiveDisk)->put($archivePath, $stream); + + $asset->fill([ + 'disk' => $archiveDisk, + 'media_storage_target_id' => $archiveTargetId, + 'status' => 'archived', + 'archived_at' => now(), + 'error_message' => null, + ])->save(); + + if ($this->deleteSource) { + Storage::disk($sourceDisk)->delete($asset->path); + } + } catch (\Throwable $e) { + Log::error('Failed to archive media asset', [ + 'asset_id' => $asset->id, + 'event_id' => $event->id, + 'source_disk' => $sourceDisk, + 'archive_disk' => $archiveDisk, + 'error' => $e->getMessage(), + ]); + + $asset->update([ + 'status' => 'failed', + 'error_message' => $e->getMessage(), + ]); + } finally { + if (is_resource($stream)) { + fclose($stream); + } + } + } + } +} diff --git a/app/Models/Event.php b/app/Models/Event.php index f5a16d6..3b5370d 100644 --- a/app/Models/Event.php +++ b/app/Models/Event.php @@ -7,6 +7,9 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; +use App\Models\EventStorageAssignment; +use App\Models\EventMediaAsset; +use App\Models\MediaStorageTarget; class Event extends Model { @@ -21,6 +24,27 @@ class Event extends Model 'description' => 'array', ]; + public function storageAssignments(): HasMany + { + return $this->hasMany(EventStorageAssignment::class); + } + + public function mediaAssets(): HasMany + { + return $this->hasMany(EventMediaAsset::class); + } + + public function currentStorageTarget(?string $role = 'hot'): ?MediaStorageTarget + { + $assignment = $this->storageAssignments() + ->where('role', $role) + ->where('status', 'active') + ->latest('assigned_at') + ->first(); + + return $assignment?->storageTarget; + } + public function tenant(): BelongsTo { return $this->belongsTo(Tenant::class); diff --git a/app/Models/EventMediaAsset.php b/app/Models/EventMediaAsset.php new file mode 100644 index 0000000..a2afbaf --- /dev/null +++ b/app/Models/EventMediaAsset.php @@ -0,0 +1,54 @@ + 'integer', + 'processed_at' => 'datetime', + 'archived_at' => 'datetime', + 'restored_at' => 'datetime', + 'meta' => 'array', + ]; + + public function event(): BelongsTo + { + return $this->belongsTo(Event::class); + } + + public function storageTarget(): BelongsTo + { + return $this->belongsTo(MediaStorageTarget::class, 'media_storage_target_id'); + } + + public function photo(): BelongsTo + { + return $this->belongsTo(Photo::class); + } +} + diff --git a/app/Models/EventStorageAssignment.php b/app/Models/EventStorageAssignment.php new file mode 100644 index 0000000..707bff9 --- /dev/null +++ b/app/Models/EventStorageAssignment.php @@ -0,0 +1,39 @@ + 'datetime', + 'released_at' => 'datetime', + 'meta' => 'array', + ]; + + public function event(): BelongsTo + { + return $this->belongsTo(Event::class); + } + + public function storageTarget(): BelongsTo + { + return $this->belongsTo(MediaStorageTarget::class, 'media_storage_target_id'); + } +} + diff --git a/app/Models/MediaStorageTarget.php b/app/Models/MediaStorageTarget.php new file mode 100644 index 0000000..a6d0624 --- /dev/null +++ b/app/Models/MediaStorageTarget.php @@ -0,0 +1,66 @@ + 'array', + 'is_hot' => 'boolean', + 'is_default' => 'boolean', + 'is_active' => 'boolean', + 'priority' => 'integer', + ]; + + public function scopeActive(Builder $query): Builder + { + return $query->where('is_active', true); + } + + public function scopeHot(Builder $query): Builder + { + return $query->where('is_hot', true); + } + + public function eventAssignments(): HasMany + { + return $this->hasMany(EventStorageAssignment::class); + } + + public function mediaAssets(): HasMany + { + return $this->hasMany(EventMediaAsset::class); + } + + public function toFilesystemConfig(): array + { + $config = $this->config ?? []; + + $base = [ + 'driver' => $this->driver, + 'throw' => false, + 'report' => false, + ]; + + return array_merge($base, $config); + } +} + diff --git a/app/Models/Photo.php b/app/Models/Photo.php index d1e4b0a..f5abc4b 100644 --- a/app/Models/Photo.php +++ b/app/Models/Photo.php @@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use App\Models\EventMediaAsset; use Znck\Eloquent\Relations\BelongsToThrough as BelongsToThroughRelation; use Znck\Eloquent\Traits\BelongsToThrough; @@ -21,6 +22,11 @@ class Photo extends Model 'metadata' => 'array', ]; + public function mediaAsset(): BelongsTo + { + return $this->belongsTo(EventMediaAsset::class, 'media_asset_id'); + } + public function getImagePathAttribute(): ?string { return $this->file_path; diff --git a/app/Notifications/UploadPipelineFailed.php b/app/Notifications/UploadPipelineFailed.php new file mode 100644 index 0000000..5edb712 --- /dev/null +++ b/app/Notifications/UploadPipelineFailed.php @@ -0,0 +1,43 @@ +context; + + return (new MailMessage) + ->subject('Upload-Pipeline Fehler: '.($context['job'] ?? 'Unbekannter Job')) + ->line('In der Upload-Pipeline ist ein Fehler aufgetreten.') + ->line('Job: '.($context['job'] ?? 'n/a')) + ->line('Queue: '.($context['queue'] ?? 'n/a')) + ->line('Event ID: '.($context['event_id'] ?? 'n/a')) + ->line('Foto ID: '.($context['photo_id'] ?? 'n/a')) + ->line('Exception: '.($context['exception'] ?? 'n/a')) + ->line('Zeitpunkt: '.now()->toDateTimeString()); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 20cfd36..fa1d4d3 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -5,8 +5,14 @@ namespace App\Providers; use App\Services\Checkout\CheckoutAssignmentService; use App\Services\Checkout\CheckoutPaymentService; use App\Services\Checkout\CheckoutSessionService; +use App\Notifications\UploadPipelineFailed; +use App\Services\Storage\EventStorageManager; +use App\Services\Storage\StorageHealthService; use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Http\Request; +use Illuminate\Queue\Events\JobFailed; +use Illuminate\Support\Facades\Notification; +use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\ServiceProvider; use Inertia\Inertia; @@ -21,6 +27,8 @@ class AppServiceProvider extends ServiceProvider $this->app->singleton(CheckoutSessionService::class); $this->app->singleton(CheckoutAssignmentService::class); $this->app->singleton(CheckoutPaymentService::class); + $this->app->singleton(EventStorageManager::class); + $this->app->singleton(StorageHealthService::class); } /** @@ -28,6 +36,8 @@ class AppServiceProvider extends ServiceProvider */ public function boot(): void { + $this->app->make(EventStorageManager::class)->registerDynamicDisks(); + RateLimiter::for('tenant-api', function (Request $request) { $tenantId = $request->attributes->get('tenant_id') ?? $request->user()?->tenant_id @@ -42,12 +52,41 @@ class AppServiceProvider extends ServiceProvider return Limit::perMinute(10)->by('oauth:' . ($request->ip() ?? 'unknown')); }); - \Inertia\Inertia::share('locale', function () { - return app()->getLocale(); - }); + Inertia::share('locale', fn () => app()->getLocale()); + + if (config('storage-monitor.queue_failure_alerts')) { + Queue::failing(function (JobFailed $event) { + $context = [ + 'queue' => $event->job->getQueue(), + 'job' => $event->job->resolveName(), + 'exception' => $event->exception->getMessage(), + ]; + + $command = data_get($event->job->payload(), 'data.command'); + + if (is_string($command)) { + try { + $instance = @unserialize($command, ['allowed_classes' => true]); + if (is_object($instance)) { + foreach (['eventId' => 'event_id', 'photoId' => 'photo_id'] as $property => $label) { + if (isset($instance->{$property})) { + $context[$label] = $instance->{$property}; + } + } + } + } catch (\Throwable $e) { + $context['unserialize_error'] = $e->getMessage(); + } + } + + if ($mail = config('storage-monitor.alert_recipients.mail')) { + Notification::route('mail', $mail)->notify(new UploadPipelineFailed($context)); + } + }); + } if ($this->app->runningInConsole()) { $this->app->register(\App\Providers\Filament\AdminPanelProvider::class); } } -} \ No newline at end of file +} diff --git a/app/Providers/Filament/SuperAdminPanelProvider.php b/app/Providers/Filament/SuperAdminPanelProvider.php index 66756a4..e3fae5d 100644 --- a/app/Providers/Filament/SuperAdminPanelProvider.php +++ b/app/Providers/Filament/SuperAdminPanelProvider.php @@ -52,6 +52,7 @@ class SuperAdminPanelProvider extends PanelProvider Widgets\FilamentInfoWidget::class, PlatformStatsWidget::class, TopTenantsByUploads::class, + \App\Filament\Widgets\StorageCapacityWidget::class, ]) ->middleware([ EncryptCookies::class, @@ -73,6 +74,7 @@ class SuperAdminPanelProvider extends PanelProvider \App\Filament\Resources\UserResource::class, \App\Filament\Resources\TenantPackageResource::class, \App\Filament\Resources\TaskResource::class, + \App\Filament\Resources\MediaStorageTargetResource::class, PostResource::class, CategoryResource::class, LegalPageResource::class, diff --git a/app/Services/Storage/EventStorageManager.php b/app/Services/Storage/EventStorageManager.php new file mode 100644 index 0000000..45abcc0 --- /dev/null +++ b/app/Services/Storage/EventStorageManager.php @@ -0,0 +1,158 @@ +currentStorageTarget('hot') ?? $this->resolveDefaultHotTarget(); + + return $target?->key ?? Config::get('filesystems.default', 'local'); + } + + public function getArchiveDiskForEvent(Event $event): ?string + { + $assignment = $event->storageAssignments() + ->where('role', 'archive') + ->where('status', 'active') + ->latest('assigned_at') + ->first(); + + if ($assignment?->storageTarget) { + return $assignment->storageTarget->key; + } + + $default = $this->resolveDefaultArchiveTarget(); + + if (! $default) { + return null; + } + + $assignment = $this->ensureAssignment($event, $default, 'archive'); + + return $assignment->storageTarget?->key; + } + + public function ensureAssignment(Event $event, ?MediaStorageTarget $target = null, string $role = 'hot'): EventStorageAssignment + { + if (! $target) { + $target = $role === 'archive' + ? $this->resolveDefaultArchiveTarget() + : $this->resolveDefaultHotTarget(); + } + + $assignment = $event->storageAssignments() + ->where('role', $role) + ->where('status', 'active') + ->where('media_storage_target_id', $target?->id) + ->latest('assigned_at') + ->first(); + + if ($assignment) { + return $assignment; + } + + if (! $target) { + throw new \RuntimeException('No storage target available for role '.$role); + } + + $assignment = $event->storageAssignments()->create([ + 'media_storage_target_id' => $target->id, + 'role' => $role, + 'status' => 'active', + 'assigned_at' => now(), + ]); + + return $assignment; + } + + public function recordAsset( + Event $event, + string $disk, + string $path, + array $attributes = [], + ?EventMediaAsset $existing = null, + ): EventMediaAsset { + $target = MediaStorageTarget::where('key', $disk)->first(); + + if (! $target) { + $target = $this->resolveDefaultHotTarget(); + } + + $payload = array_merge([ + 'event_id' => $event->id, + 'media_storage_target_id' => $target?->id, + 'disk' => $disk, + 'path' => $path, + 'status' => 'hot', + 'processed_at' => now(), + ], $attributes); + + if ($existing) { + $existing->fill($payload)->save(); + + return $existing; + } + + return EventMediaAsset::create($payload); + } + + public function registerDynamicDisks(): void + { + if (! $this->targetsTableExists()) { + return; + } + + $targets = MediaStorageTarget::active()->get(); + + foreach ($targets as $target) { + $config = $target->toFilesystemConfig(); + Config::set('filesystems.disks.'.$target->key, $config); + } + } + + protected function resolveDefaultHotTarget(): ?MediaStorageTarget + { + return MediaStorageTarget::active() + ->where('is_hot', true) + ->orderByDesc('is_default') + ->orderByDesc('priority') + ->first(); + } + + protected function resolveDefaultArchiveTarget(): ?MediaStorageTarget + { + return MediaStorageTarget::active() + ->where('is_hot', false) + ->orderByDesc('priority') + ->first(); + } + + protected function targetsTableExists(): bool + { + static $cached; + + if ($cached !== null) { + return $cached; + } + + try { + $cached = \Schema::hasTable('media_storage_targets'); + } catch (\Throwable $e) { + Log::debug('Skipping storage target bootstrap: '.$e->getMessage()); + $cached = false; + } + + return $cached; + } +} diff --git a/app/Services/Storage/StorageHealthService.php b/app/Services/Storage/StorageHealthService.php new file mode 100644 index 0000000..c9e7b41 --- /dev/null +++ b/app/Services/Storage/StorageHealthService.php @@ -0,0 +1,63 @@ +config['monitor_path'] ?? $target->config['root'] ?? null; + + if (! $monitorPath || ! file_exists($monitorPath)) { + return [ + 'status' => 'unknown', + 'total' => null, + 'free' => null, + 'used' => null, + 'percentage' => null, + 'path' => $monitorPath, + ]; + } + + try { + $total = @disk_total_space($monitorPath); + $free = @disk_free_space($monitorPath); + + if ($total === false || $free === false) { + return [ + 'status' => 'unavailable', + 'total' => null, + 'free' => null, + 'used' => null, + 'percentage' => null, + 'path' => $monitorPath, + ]; + } + + $used = $total - $free; + $percentage = $total > 0 ? round(($used / $total) * 100, 1) : null; + + return [ + 'status' => 'ok', + 'total' => $total, + 'free' => $free, + 'used' => $used, + 'percentage' => $percentage, + 'path' => $monitorPath, + ]; + } catch (\Throwable $e) { + return [ + 'status' => 'error', + 'message' => $e->getMessage(), + 'total' => null, + 'free' => null, + 'used' => null, + 'percentage' => null, + 'path' => $monitorPath, + ]; + } + } +} + diff --git a/config/storage-monitor.php b/config/storage-monitor.php new file mode 100644 index 0000000..248b9a8 --- /dev/null +++ b/config/storage-monitor.php @@ -0,0 +1,10 @@ + [ + 'mail' => env('STORAGE_ALERT_EMAIL'), + ], + + 'queue_failure_alerts' => env('STORAGE_QUEUE_FAILURE_ALERTS', true), +]; + diff --git a/cron/archive_dispatcher.sh b/cron/archive_dispatcher.sh new file mode 100644 index 0000000..3f7c2b2 --- /dev/null +++ b/cron/archive_dispatcher.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash + +# Archive dispatcher cron skeleton +# Run nightly to move completed events to cold storage + +set -euo pipefail + +APP_DIR="$(cd "$(dirname "$0")/.." && pwd)" +cd "$APP_DIR" + +# Replace with finalized artisan command that queues archive jobs +/usr/bin/env php artisan storage:archive-pending --quiet + diff --git a/cron/storage_monitor.sh b/cron/storage_monitor.sh new file mode 100644 index 0000000..3726b63 --- /dev/null +++ b/cron/storage_monitor.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash + +# Storage monitor cron skeleton +# Usage: configure cron to run every 5 minutes + +set -euo pipefail + +APP_DIR="$(cd "$(dirname "$0")/.." && pwd)" +cd "$APP_DIR" + +# Collect storage statistics and cache them for the dashboard +# Customize the artisan command once implemented +/usr/bin/env php artisan storage:monitor --quiet + diff --git a/cron/upload_queue_health.sh b/cron/upload_queue_health.sh new file mode 100644 index 0000000..e119e6c --- /dev/null +++ b/cron/upload_queue_health.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash + +# Upload queue health cron skeleton +# Schedule every 10 minutes to detect stalled uploads + +set -euo pipefail + +APP_DIR="$(cd "$(dirname "$0")/.." && pwd)" +cd "$APP_DIR" + +# Artisan command should inspect queue lengths and stuck assets +/usr/bin/env php artisan storage:check-upload-queues --quiet + diff --git a/database/migrations/2025_10_20_090000_create_media_storage_targets_table.php b/database/migrations/2025_10_20_090000_create_media_storage_targets_table.php new file mode 100644 index 0000000..24bcd15 --- /dev/null +++ b/database/migrations/2025_10_20_090000_create_media_storage_targets_table.php @@ -0,0 +1,36 @@ +id(); + $table->string('key')->unique(); + $table->string('name'); + $table->string('driver')->default('local'); + $table->json('config')->nullable(); + $table->boolean('is_hot')->default(false); + $table->boolean('is_default')->default(false); + $table->boolean('is_active')->default(true); + $table->unsignedInteger('priority')->default(0); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('media_storage_targets'); + } +}; + diff --git a/database/migrations/2025_10_20_090100_create_event_storage_assignments_table.php b/database/migrations/2025_10_20_090100_create_event_storage_assignments_table.php new file mode 100644 index 0000000..c3840e6 --- /dev/null +++ b/database/migrations/2025_10_20_090100_create_event_storage_assignments_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('event_id')->constrained()->cascadeOnDelete(); + $table->foreignId('media_storage_target_id')->constrained()->cascadeOnDelete(); + $table->string('role')->default('hot'); // hot, archive + $table->string('status')->default('active'); // active, pending, archived, restoring + $table->timestamp('assigned_at')->nullable(); + $table->timestamp('released_at')->nullable(); + $table->json('meta')->nullable(); + $table->timestamps(); + + $table->index(['event_id', 'role', 'status']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('event_storage_assignments'); + } +}; + diff --git a/database/migrations/2025_10_20_090200_create_event_media_assets_table.php b/database/migrations/2025_10_20_090200_create_event_media_assets_table.php new file mode 100644 index 0000000..d37cd7f --- /dev/null +++ b/database/migrations/2025_10_20_090200_create_event_media_assets_table.php @@ -0,0 +1,46 @@ +id(); + $table->foreignId('event_id')->constrained()->cascadeOnDelete(); + $table->foreignId('media_storage_target_id')->constrained()->cascadeOnDelete(); + $table->foreignId('photo_id')->nullable()->constrained('photos')->nullOnDelete(); + $table->string('variant')->default('original'); // original, thumbnail, etc. + $table->string('disk'); + $table->string('path'); + $table->unsignedBigInteger('size_bytes')->nullable(); + $table->string('checksum')->nullable(); + $table->string('mime_type')->nullable(); + $table->string('status')->default('pending'); // pending, hot, archived, restoring, failed + $table->timestamp('processed_at')->nullable(); + $table->timestamp('archived_at')->nullable(); + $table->timestamp('restored_at')->nullable(); + $table->text('error_message')->nullable(); + $table->json('meta')->nullable(); + $table->timestamps(); + + $table->index(['event_id', 'variant', 'status']); + $table->index(['media_storage_target_id', 'status']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('event_media_assets'); + } +}; + diff --git a/database/migrations/2025_10_20_090300_add_media_asset_id_to_photos_table.php b/database/migrations/2025_10_20_090300_add_media_asset_id_to_photos_table.php new file mode 100644 index 0000000..431fb1f --- /dev/null +++ b/database/migrations/2025_10_20_090300_add_media_asset_id_to_photos_table.php @@ -0,0 +1,27 @@ +foreignId('media_asset_id') + ->nullable() + ->after('file_path') + ->constrained('event_media_assets') + ->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('photos', function (Blueprint $table) { + $table->dropConstrainedForeignId('media_asset_id'); + }); + } +}; + diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index e93a9f4..70f6993 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -13,6 +13,7 @@ class DatabaseSeeder extends Seeder { // Seed basic system data $this->call([ + MediaStorageTargetSeeder::class, LegalPagesSeeder::class, PackageSeeder::class, ]); diff --git a/database/seeders/MediaStorageTargetSeeder.php b/database/seeders/MediaStorageTargetSeeder.php new file mode 100644 index 0000000..5e1b207 --- /dev/null +++ b/database/seeders/MediaStorageTargetSeeder.php @@ -0,0 +1,56 @@ + 'local-ssd', + 'name' => 'Local SSD (Hot Storage)', + 'driver' => 'local', + 'config' => [ + 'root' => storage_path('app/public'), + 'visibility' => 'public', + 'url' => rtrim(config('app.url', env('APP_URL', 'http://localhost')), '/').'/storage', + 'monitor_path' => storage_path('app/public'), + ], + 'is_hot' => true, + 'is_default' => true, + 'priority' => 100, + ], + [ + 'key' => 'hetzner-archive', + 'name' => 'Hetzner Storage Box (Archive)', + 'driver' => 'sftp', + 'config' => [ + 'host' => env('HETZNER_STORAGE_HOST', 'storagebox.example.com'), + 'username' => env('HETZNER_STORAGE_USERNAME', 'u000000'), + 'password' => env('HETZNER_STORAGE_PASSWORD'), + 'port' => (int) env('HETZNER_STORAGE_PORT', 22), + 'root' => env('HETZNER_STORAGE_ROOT', '/fotospiel'), + 'timeout' => 30, + 'monitor_path' => env('HETZNER_STORAGE_MONITOR_PATH', '/mnt/hetzner'), + ], + 'is_hot' => false, + 'is_default' => false, + 'priority' => 50, + ], + ]; + + foreach ($targets as $payload) { + $config = Arr::pull($payload, 'config'); + + MediaStorageTarget::updateOrCreate( + ['key' => $payload['key']], + array_merge($payload, ['config' => $config]) + ); + } + } +} diff --git a/database/seeders/PackageSeeder.php b/database/seeders/PackageSeeder.php index f819157..bdcc0cf 100644 --- a/database/seeders/PackageSeeder.php +++ b/database/seeders/PackageSeeder.php @@ -14,37 +14,7 @@ class PackageSeeder extends Seeder public function run(): void { $packages = [ - [ - 'slug' => 'free-package', - 'name' => 'Free / Test', - 'name_translations' => [ - 'de' => 'Free / Test', - 'en' => 'Free / Test', - ], - 'type' => PackageType::ENDCUSTOMER, - 'price' => 0.00, - 'max_photos' => 120, - 'max_guests' => 25, - 'gallery_days' => 7, - 'max_tasks' => 8, - 'watermark_allowed' => true, - 'branding_allowed' => false, - 'features' => ['basic_uploads', 'limited_sharing'], - 'description' => << [ - 'de' => 'Perfekt zum Ausprobieren: Teile erste Eindrücke mit {{max_guests}} Gästen und sammle {{max_photos}} Bilder in einer Test-Galerie, die {{gallery_duration}} online bleibt. Ideal für kleine Runden oder interne Demos.', - 'en' => 'Perfect for trying it out: share first impressions with {{max_guests}} guests and collect {{max_photos}} photos in a test gallery that stays online for {{gallery_duration}}. Ideal for small groups or internal demos.', - ], - 'description_table' => [ - ['title' => 'Fotos', 'value' => '{{max_photos}}'], - ['title' => 'Gäste', 'value' => '{{max_guests}}'], - ['title' => 'Aufgaben', 'value' => '{{max_tasks}} Fotoaufgaben'], - ['title' => 'Galerie', 'value' => '{{gallery_duration}}'], - ['title' => 'Branding', 'value' => 'Fotospiel Standard Branding'], - ], - ], + [ 'slug' => 'starter', 'name' => 'Starter', @@ -53,7 +23,7 @@ TEXT, 'en' => 'Starter', ], 'type' => PackageType::ENDCUSTOMER, - 'price' => 59.00, + 'price' => 29.00, 'max_photos' => 300, 'max_guests' => 50, 'gallery_days' => 14, @@ -84,7 +54,7 @@ TEXT, 'en' => 'Standard', ], 'type' => PackageType::ENDCUSTOMER, - 'price' => 129.00, + 'price' => 59.00, 'max_photos' => 1000, 'max_guests' => 150, 'gallery_days' => 30, @@ -115,7 +85,7 @@ TEXT, 'en' => 'Premium', ], 'type' => PackageType::ENDCUSTOMER, - 'price' => 249.00, + 'price' => 129.00, 'max_photos' => 3000, 'max_guests' => 500, 'gallery_days' => 180, @@ -146,7 +116,7 @@ TEXT, 'en' => 'Reseller S', ], 'type' => PackageType::RESELLER, - 'price' => 299.00, + 'price' => 149.00, 'max_photos' => 1000, 'max_guests' => null, 'gallery_days' => 30, @@ -177,7 +147,7 @@ TEXT, 'en' => 'Reseller M', ], 'type' => PackageType::RESELLER, - 'price' => 599.00, + 'price' => 349.00, 'max_photos' => 1500, 'max_guests' => null, 'gallery_days' => 60, @@ -208,7 +178,7 @@ TEXT, 'en' => 'Reseller L', ], 'type' => PackageType::RESELLER, - 'price' => 1199.00, + 'price' => 699.00, 'max_photos' => 3000, 'max_guests' => null, 'gallery_days' => 90, @@ -239,7 +209,7 @@ TEXT, 'en' => 'Enterprise / Unlimited', ], 'type' => PackageType::RESELLER, - 'price' => 0.00, + 'price' => 1999.00, 'max_photos' => null, 'max_guests' => null, 'gallery_days' => null, diff --git a/routes/console.php b/routes/console.php index 3c9adf1..219ee87 100644 --- a/routes/console.php +++ b/routes/console.php @@ -6,3 +6,15 @@ use Illuminate\Support\Facades\Artisan; Artisan::command('inspire', function () { $this->comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); + +Artisan::command('storage:monitor', function () { + $this->comment('Storage monitor placeholder – implement metrics collection here.'); +})->purpose('Collect storage capacity statistics for dashboards'); + +Artisan::command('storage:archive-pending', function () { + $this->comment('Archive dispatcher placeholder – enqueue archive jobs here.'); +})->purpose('Dispatch archive jobs for events ready to move to cold storage'); + +Artisan::command('storage:check-upload-queues', function () { + $this->comment('Upload queue health placeholder – verify upload pipelines and report issues.'); +})->purpose('Check upload queues for stalled or failed jobs and alert admins');