Admin Menü neu geordnet.
Introduced a two-tier media pipeline with dynamic disks, asset tracking, admin controls, and alerting around
upload/archival workflows.
- Added storage metadata + asset tables and models so every photo/variant knows where it lives
(database/migrations/2025_10_20_090000_create_media_storage_targets_table.php, database/ migrations/2025_10_20_090200_create_event_media_assets_table.php, app/Models/MediaStorageTarget.php:1, app/
Models/EventMediaAsset.php:1, app/Models/EventStorageAssignment.php:1, app/Models/Event.php:27).
- Rewired guest and tenant uploads to pick the event’s hot disk, persist EventMediaAsset records, compute
checksums, and clean up on delete (app/Http/Controllers/Api/EventPublicController.php:243, app/Http/
Controllers/Api/Tenant/PhotoController.php:25, app/Models/Photo.php:25).
- Implemented storage services, archival job scaffolding, monitoring config, and queue-failure notifications for upload issues (app/Services/Storage/EventStorageManager.php:16, app/Services/Storage/
StorageHealthService.php:9, app/Jobs/ArchiveEventMediaAssets.php:16, app/Providers/AppServiceProvider.php:39, app/Notifications/UploadPipelineFailed.php:8, config/storage-monitor.php:1).
- Seeded default hot/cold targets and exposed super-admin tooling via a Filament resource and capacity widget (database/seeders/MediaStorageTargetSeeder.php:13, database/seeders/DatabaseSeeder.php:17, app/Filament/Resources/MediaStorageTargetResource.php:1, app/Filament/Widgets/StorageCapacityWidget.php:12, app/Providers/Filament/SuperAdminPanelProvider.php:47).
- Dropped cron skeletons and artisan placeholders to schedule storage monitoring, archival dispatch, and upload queue health checks (cron/storage_monitor.sh, cron/archive_dispatcher.sh, cron/upload_queue_health.sh, routes/console.php:9).
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
130
app/Filament/Resources/MediaStorageTargetResource.php
Normal file
130
app/Filament/Resources/MediaStorageTargetResource.php
Normal file
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\MediaStorageTargetResource\Pages;
|
||||
use App\Models\MediaStorageTarget;
|
||||
use Filament\Actions;
|
||||
use Filament\Forms\Components\KeyValue;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use UnitEnum;
|
||||
use BackedEnum;
|
||||
|
||||
class MediaStorageTargetResource extends Resource
|
||||
{
|
||||
protected static ?string $model = MediaStorageTarget::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-server';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = null;
|
||||
|
||||
public static function getNavigationGroup(): UnitEnum|string|null
|
||||
{
|
||||
return __('admin.nav.platform_management');
|
||||
}
|
||||
|
||||
protected static ?int $navigationSort = 60;
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema->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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\MediaStorageTargetResource\Pages;
|
||||
|
||||
use App\Filament\Resources\MediaStorageTargetResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateMediaStorageTarget extends CreateRecord
|
||||
{
|
||||
protected static string $resource = MediaStorageTargetResource::class;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\MediaStorageTargetResource\Pages;
|
||||
|
||||
use App\Filament\Resources\MediaStorageTargetResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditMediaStorageTarget extends EditRecord
|
||||
{
|
||||
protected static string $resource = MediaStorageTargetResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\MediaStorageTargetResource\Pages;
|
||||
|
||||
use App\Filament\Resources\MediaStorageTargetResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListMediaStorageTargets extends ListRecords
|
||||
{
|
||||
protected static string $resource = MediaStorageTargetResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,11 @@ class PackageResource extends Resource
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = null;
|
||||
|
||||
public static function getNavigationGroup(): UnitEnum|string|null
|
||||
{
|
||||
return __('admin.nav.platform_management');
|
||||
}
|
||||
|
||||
protected static ?int $navigationSort = 5;
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
@@ -243,10 +248,12 @@ class PackageResource extends Resource
|
||||
{
|
||||
return [
|
||||
'basic_uploads' => '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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -36,7 +36,7 @@ class PurchaseResource extends Resource
|
||||
|
||||
public static function getNavigationGroup(): string
|
||||
{
|
||||
return 'Billing';
|
||||
return 'Billing & Finanzen';
|
||||
}
|
||||
|
||||
protected static ?int $navigationSort = 10;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
76
app/Filament/Widgets/StorageCapacityWidget.php
Normal file
76
app/Filament/Widgets/StorageCapacityWidget.php
Normal file
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Widgets;
|
||||
|
||||
use App\Models\MediaStorageTarget;
|
||||
use App\Services\Storage\StorageHealthService;
|
||||
use Filament\Widgets\StatsOverviewWidget;
|
||||
use Filament\Widgets\StatsOverviewWidget\Card;
|
||||
|
||||
class StorageCapacityWidget extends StatsOverviewWidget
|
||||
{
|
||||
protected static ?int $sort = 1;
|
||||
|
||||
protected function getCards(): array
|
||||
{
|
||||
$health = app(StorageHealthService::class);
|
||||
|
||||
return MediaStorageTarget::all()
|
||||
->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];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
103
app/Jobs/ArchiveEventMediaAssets.php
Normal file
103
app/Jobs/ArchiveEventMediaAssets.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\EventMediaAsset;
|
||||
use App\Services\Storage\EventStorageManager;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class ArchiveEventMediaAssets implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public int $eventId,
|
||||
public bool $deleteSource = true,
|
||||
public ?int $initiatorId = null,
|
||||
) {
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
54
app/Models/EventMediaAsset.php
Normal file
54
app/Models/EventMediaAsset.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class EventMediaAsset extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'event_id',
|
||||
'media_storage_target_id',
|
||||
'photo_id',
|
||||
'variant',
|
||||
'disk',
|
||||
'path',
|
||||
'size_bytes',
|
||||
'checksum',
|
||||
'mime_type',
|
||||
'status',
|
||||
'processed_at',
|
||||
'archived_at',
|
||||
'restored_at',
|
||||
'error_message',
|
||||
'meta',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'size_bytes' => '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);
|
||||
}
|
||||
}
|
||||
|
||||
39
app/Models/EventStorageAssignment.php
Normal file
39
app/Models/EventStorageAssignment.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class EventStorageAssignment extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'event_id',
|
||||
'media_storage_target_id',
|
||||
'role',
|
||||
'status',
|
||||
'assigned_at',
|
||||
'released_at',
|
||||
'meta',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'assigned_at' => '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');
|
||||
}
|
||||
}
|
||||
|
||||
66
app/Models/MediaStorageTarget.php
Normal file
66
app/Models/MediaStorageTarget.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class MediaStorageTarget extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'key',
|
||||
'name',
|
||||
'driver',
|
||||
'config',
|
||||
'is_hot',
|
||||
'is_default',
|
||||
'is_active',
|
||||
'priority',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'config' => '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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
43
app/Notifications/UploadPipelineFailed.php
Normal file
43
app/Notifications/UploadPipelineFailed.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class UploadPipelineFailed extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(private readonly array $context)
|
||||
{
|
||||
}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
$channels = [];
|
||||
|
||||
if (config('storage-monitor.alert_recipients.mail')) {
|
||||
$channels[] = 'mail';
|
||||
}
|
||||
|
||||
return $channels;
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$context = $this->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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
158
app/Services/Storage/EventStorageManager.php
Normal file
158
app/Services/Storage/EventStorageManager.php
Normal file
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Storage;
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\EventMediaAsset;
|
||||
use App\Models\EventStorageAssignment;
|
||||
use App\Models\MediaStorageTarget;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class EventStorageManager
|
||||
{
|
||||
public function getHotDiskForEvent(Event $event): string
|
||||
{
|
||||
$target = $event->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;
|
||||
}
|
||||
}
|
||||
63
app/Services/Storage/StorageHealthService.php
Normal file
63
app/Services/Storage/StorageHealthService.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Storage;
|
||||
|
||||
use App\Models\MediaStorageTarget;
|
||||
|
||||
class StorageHealthService
|
||||
{
|
||||
public function getCapacity(MediaStorageTarget $target): array
|
||||
{
|
||||
$monitorPath = $target->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,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
10
config/storage-monitor.php
Normal file
10
config/storage-monitor.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'alert_recipients' => [
|
||||
'mail' => env('STORAGE_ALERT_EMAIL'),
|
||||
],
|
||||
|
||||
'queue_failure_alerts' => env('STORAGE_QUEUE_FAILURE_ALERTS', true),
|
||||
];
|
||||
|
||||
13
cron/archive_dispatcher.sh
Normal file
13
cron/archive_dispatcher.sh
Normal file
@@ -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
|
||||
|
||||
14
cron/storage_monitor.sh
Normal file
14
cron/storage_monitor.sh
Normal file
@@ -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
|
||||
|
||||
13
cron/upload_queue_health.sh
Normal file
13
cron/upload_queue_health.sh
Normal file
@@ -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
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('media_storage_targets', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('event_storage_assignments', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('event_media_assets', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('photos', function (Blueprint $table) {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ class DatabaseSeeder extends Seeder
|
||||
{
|
||||
// Seed basic system data
|
||||
$this->call([
|
||||
MediaStorageTargetSeeder::class,
|
||||
LegalPagesSeeder::class,
|
||||
PackageSeeder::class,
|
||||
]);
|
||||
|
||||
56
database/seeders/MediaStorageTargetSeeder.php
Normal file
56
database/seeders/MediaStorageTargetSeeder.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\MediaStorageTarget;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class MediaStorageTargetSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$targets = [
|
||||
[
|
||||
'key' => '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])
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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' => <<<TEXT
|
||||
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.
|
||||
TEXT,
|
||||
'description_translations' => [
|
||||
'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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user