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:
Codex Agent
2025-10-17 22:26:13 +02:00
parent 48a2974152
commit 5817270c35
44 changed files with 1336 additions and 72 deletions

View File

@@ -44,6 +44,11 @@ class CategoryResource extends Resource
protected static ?string $modelLabel = 'Kategorie'; protected static ?string $modelLabel = 'Kategorie';
public static function getNavigationGroup(): string
{
return 'Content & Bibliothek';
}
public static function form(Schema $schema): Schema public static function form(Schema $schema): Schema
{ {
return $schema return $schema

View File

@@ -51,6 +51,11 @@ class PostResource extends Resource
protected static ?string $modelLabel = 'Beitrag'; protected static ?string $modelLabel = 'Beitrag';
public static function getNavigationGroup(): string
{
return 'Content & Bibliothek';
}
public static function form(Schema $schema): Schema public static function form(Schema $schema): Schema
{ {
return $schema return $schema

View File

@@ -28,7 +28,7 @@ class EmotionResource extends Resource
public static function getNavigationGroup(): UnitEnum|string|null public static function getNavigationGroup(): UnitEnum|string|null
{ {
return __('admin.nav.library'); return __('admin.nav.tasks_emotions');
} }
protected static ?int $navigationSort = 10; protected static ?int $navigationSort = 10;

View File

@@ -37,6 +37,11 @@ class EventPurchaseResource extends Resource
return false; return false;
} }
public static function getNavigationGroup(): string
{
return 'Billing & Finanzen';
}
public static function form(Schema $schema): Schema public static function form(Schema $schema): Schema
{ {
return $schema return $schema

View File

@@ -34,7 +34,7 @@ class EventResource extends Resource
public static function getNavigationGroup(): UnitEnum|string|null public static function getNavigationGroup(): UnitEnum|string|null
{ {
return __('admin.nav.platform'); return __('admin.nav.event_management');
} }
public static function form(Schema $form): Schema public static function form(Schema $form): Schema

View File

@@ -25,7 +25,7 @@ class EventTypeResource extends Resource
public static function getNavigationGroup(): UnitEnum|string|null public static function getNavigationGroup(): UnitEnum|string|null
{ {
return __('admin.nav.library'); return __('admin.nav.event_management');
} }
protected static ?int $navigationSort = 20; protected static ?int $navigationSort = 20;

View File

@@ -27,7 +27,7 @@ class LegalPageResource extends Resource
public static function getNavigationGroup(): UnitEnum|string|null public static function getNavigationGroup(): UnitEnum|string|null
{ {
return __('admin.nav.platform'); return __('admin.nav.content_library');
} }
protected static ?int $navigationSort = 40; protected static ?int $navigationSort = 40;

View 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'),
];
}
}

View File

@@ -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;
}

View File

@@ -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(),
];
}
}

View File

@@ -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(),
];
}
}

View File

@@ -35,6 +35,11 @@ class PackageResource extends Resource
protected static string|UnitEnum|null $navigationGroup = null; protected static string|UnitEnum|null $navigationGroup = null;
public static function getNavigationGroup(): UnitEnum|string|null
{
return __('admin.nav.platform_management');
}
protected static ?int $navigationSort = 5; protected static ?int $navigationSort = 5;
public static function form(Schema $schema): Schema public static function form(Schema $schema): Schema
@@ -243,10 +248,12 @@ class PackageResource extends Resource
{ {
return [ return [
'basic_uploads' => 'Basis-Uploads', 'basic_uploads' => 'Basis-Uploads',
'limited_sharing' => 'Begrenztes Teilen',
'unlimited_sharing' => 'Unbegrenztes Teilen', 'unlimited_sharing' => 'Unbegrenztes Teilen',
'no_watermark' => 'Kein Wasserzeichen', 'no_watermark' => 'Kein Wasserzeichen',
'custom_branding' => 'Eigenes Branding', 'custom_branding' => 'Eigenes Branding',
'custom_tasks' => 'Eigene Aufgaben', 'custom_tasks' => 'Eigene Aufgaben',
'reseller_dashboard' => 'Reseller-Dashboard',
'advanced_analytics' => 'Erweiterte Analytics', 'advanced_analytics' => 'Erweiterte Analytics',
'advanced_reporting' => 'Erweiterte Reports', 'advanced_reporting' => 'Erweiterte Reports',
'live_slideshow' => 'Live-Slideshow', 'live_slideshow' => 'Live-Slideshow',

View File

@@ -30,7 +30,7 @@ class PhotoResource extends Resource
public static function getNavigationGroup(): UnitEnum|string|null public static function getNavigationGroup(): UnitEnum|string|null
{ {
return __('admin.nav.content'); return __('admin.nav.event_management');
} }
public static function form(Schema $form): Schema public static function form(Schema $form): Schema

View File

@@ -36,7 +36,7 @@ class PurchaseResource extends Resource
public static function getNavigationGroup(): string public static function getNavigationGroup(): string
{ {
return 'Billing'; return 'Billing & Finanzen';
} }
protected static ?int $navigationSort = 10; protected static ?int $navigationSort = 10;

View File

@@ -28,7 +28,7 @@ class TaskResource extends Resource
public static function getNavigationGroup(): UnitEnum|string|null public static function getNavigationGroup(): UnitEnum|string|null
{ {
return __('admin.nav.library'); return __('admin.nav.tasks_emotions');
} }
public static function getNavigationLabel(): string public static function getNavigationLabel(): string

View File

@@ -32,6 +32,11 @@ class TenantPackageResource extends Resource
protected static ?string $slug = 'tenant-packages'; protected static ?string $slug = 'tenant-packages';
public static function getNavigationGroup(): string
{
return 'Billing & Finanzen';
}
public static function form(Schema $form): Schema public static function form(Schema $form): Schema
{ {
return $form return $form

View File

@@ -32,7 +32,7 @@ class TenantResource extends Resource
public static function getNavigationGroup(): UnitEnum|string|null public static function getNavigationGroup(): UnitEnum|string|null
{ {
return __('admin.nav.platform'); return __('admin.nav.platform_management');
} }
protected static ?int $navigationSort = 10; protected static ?int $navigationSort = 10;

View File

@@ -30,6 +30,11 @@ class UserResource extends Resource
protected static ?string $slug = 'users'; protected static ?string $slug = 'users';
public static function getNavigationGroup(): string
{
return 'Plattform-Verwaltung';
}
public static function form(Schema $form): Schema public static function form(Schema $form): Schema
{ {
return $form return $form

View 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];
}
}

View File

@@ -13,13 +13,17 @@ use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use App\Support\ImageHelper; use App\Support\ImageHelper;
use App\Services\EventJoinTokenService; use App\Services\EventJoinTokenService;
use App\Services\Storage\EventStorageManager;
use App\Models\Event;
use App\Models\EventJoinToken; use App\Models\EventJoinToken;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
class EventPublicController extends BaseController 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; [$event] = $result;
$eventId = $event->id; $eventId = $event->id;
$eventModel = Event::with('storageAssignments.storageTarget')->findOrFail($eventId);
// Approximate online guests as distinct recent uploaders in last 10 minutes. // Approximate online guests as distinct recent uploaders in last 10 minutes.
$tenMinutesAgo = CarbonImmutable::now()->subMinutes(10); $tenMinutesAgo = CarbonImmutable::now()->subMinutes(10);
@@ -585,16 +590,19 @@ class EventPublicController extends BaseController
]); ]);
$file = $validated['photo']; $file = $validated['photo'];
$path = Storage::disk('public')->putFile("events/{$eventId}/photos", $file); $disk = $this->eventStorageManager->getHotDiskForEvent($eventModel);
$url = Storage::url($path); $path = Storage::disk($disk)->putFile("events/{$eventId}/photos", $file);
$url = $this->resolveDiskUrl($disk, $path);
// Generate thumbnail (JPEG) under photos/thumbs // Generate thumbnail (JPEG) under photos/thumbs
$baseName = pathinfo($path, PATHINFO_FILENAME); $baseName = pathinfo($path, PATHINFO_FILENAME);
$thumbRel = "events/{$eventId}/photos/thumbs/{$baseName}_thumb.jpg"; $thumbRel = "events/{$eventId}/photos/thumbs/{$baseName}_thumb.jpg";
$thumbPath = ImageHelper::makeThumbnailOnDisk('public', $path, $thumbRel, 640, 82); $thumbPath = ImageHelper::makeThumbnailOnDisk($disk, $path, $thumbRel, 640, 82);
$thumbUrl = $thumbPath ? Storage::url($thumbPath) : $url; $thumbUrl = $thumbPath
? $this->resolveDiskUrl($disk, $thumbPath)
: $url;
$id = DB::table('photos')->insertGetId([ $photoId = DB::table('photos')->insertGetId([
'event_id' => $eventId, 'event_id' => $eventId,
'task_id' => $validated['task_id'] ?? null, 'task_id' => $validated['task_id'] ?? null,
'guest_name' => $validated['guest_name'] ?? $deviceId, 'guest_name' => $validated['guest_name'] ?? $deviceId,
@@ -610,8 +618,38 @@ class EventPublicController extends BaseController
'updated_at' => now(), '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([ return response()->json([
'id' => $id, 'id' => $photoId,
'file_path' => $url, 'file_path' => $url,
'thumbnail_path' => $thumbUrl, 'thumbnail_path' => $thumbUrl,
], 201); ], 201);
@@ -876,3 +914,18 @@ class EventPublicController extends BaseController
->header('ETag', $etag); ->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;
}
}

View File

@@ -8,6 +8,7 @@ use App\Http\Resources\Tenant\PhotoResource;
use App\Models\Event; use App\Models\Event;
use App\Models\Photo; use App\Models\Photo;
use App\Support\ImageHelper; use App\Support\ImageHelper;
use App\Services\Storage\EventStorageManager;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection; use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
@@ -16,9 +17,14 @@ use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Support\Facades\Log;
use App\Models\EventMediaAsset;
class PhotoController extends Controller class PhotoController extends Controller
{ {
public function __construct(private readonly EventStorageManager $eventStorageManager)
{
}
/** /**
* Display a listing of the event's photos. * 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 // Generate unique filename
$extension = $file->getClientOriginalExtension(); $extension = $file->getClientOriginalExtension();
$filename = Str::uuid() . '.' . $extension; $filename = Str::uuid() . '.' . $extension;
$path = "events/{$eventSlug}/photos/{$filename}"; $path = "events/{$eventSlug}/photos/{$filename}";
// Store original file // 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 // Generate thumbnail
$thumbnailPath = "events/{$eventSlug}/thumbnails/{$filename}"; $thumbnailPath = "events/{$eventSlug}/thumbnails/{$filename}";
$thumbnailRelative = ImageHelper::makeThumbnailOnDisk('public', $path, $thumbnailPath, 400); $thumbnailRelative = ImageHelper::makeThumbnailOnDisk($disk, $path, $thumbnailPath, 400);
if ($thumbnailRelative) { if ($thumbnailRelative) {
$thumbnailPath = $thumbnailRelative; $thumbnailPath = $thumbnailRelative;
} }
@@ -105,7 +115,7 @@ class PhotoController extends Controller
'size' => $file->getSize(), 'size' => $file->getSize(),
'path' => $path, 'path' => $path,
'thumbnail_path' => $thumbnailPath, 'thumbnail_path' => $thumbnailPath,
'width' => null, // To be filled by image processing 'width' => null, // Filled below
'height' => null, 'height' => null,
'status' => 'pending', // Requires moderation 'status' => 'pending', // Requires moderation
'uploader_id' => null, 'uploader_id' => null,
@@ -113,8 +123,38 @@ class PhotoController extends Controller
'user_agent' => $request->userAgent(), '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 // Get image dimensions
list($width, $height) = getimagesize($file->getRealPath()); [$width, $height] = getimagesize($file->getRealPath());
$photo->update(['width' => $width, 'height' => $height]); $photo->update(['width' => $width, 'height' => $height]);
$photo->load('event')->loadCount('likes'); $photo->load('event')->loadCount('likes');
@@ -202,15 +242,33 @@ class PhotoController extends Controller
return response()->json(['error' => 'Photo not found'], 404); return response()->json(['error' => 'Photo not found'], 404);
} }
// Delete from storage $assets = EventMediaAsset::where('photo_id', $photo->id)->get();
Storage::disk('public')->delete([
$photo->path, foreach ($assets as $asset) {
$photo->thumbnail_path, 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 // Delete record and likes
DB::transaction(function () use ($photo) { DB::transaction(function () use ($photo, $assets) {
$photo->likes()->delete(); $photo->likes()->delete();
if ($assets->isNotEmpty()) {
EventMediaAsset::whereIn('id', $assets->pluck('id'))->delete();
}
$photo->delete(); $photo->delete();
}); });
@@ -474,16 +532,19 @@ class PhotoController extends Controller
return response()->json(['error' => 'Invalid event ID'], 400); return response()->json(['error' => 'Invalid event ID'], 400);
} }
$event->load('storageAssignments.storageTarget');
$disk = $this->eventStorageManager->getHotDiskForEvent($event);
$file = $request->file('photo'); $file = $request->file('photo');
$filename = $request->filename; $filename = $request->filename;
$path = "events/{$eventSlug}/photos/{$filename}"; $path = "events/{$eventSlug}/photos/{$filename}";
// Store file // Store file
Storage::disk('public')->put($path, file_get_contents($file->getRealPath())); Storage::disk($disk)->put($path, file_get_contents($file->getRealPath()));
// Generate thumbnail // Generate thumbnail
$thumbnailPath = "events/{$eventSlug}/thumbnails/{$filename}"; $thumbnailPath = "events/{$eventSlug}/thumbnails/{$filename}";
$thumbnailRelative = ImageHelper::makeThumbnailOnDisk('public', $path, $thumbnailPath, 400); $thumbnailRelative = ImageHelper::makeThumbnailOnDisk($disk, $path, $thumbnailPath, 400);
if ($thumbnailRelative) { if ($thumbnailRelative) {
$thumbnailPath = $thumbnailRelative; $thumbnailPath = $thumbnailRelative;
} }
@@ -502,10 +563,38 @@ class PhotoController extends Controller
'user_agent' => $request->userAgent(), 'user_agent' => $request->userAgent(),
]); ]);
// Get dimensions [$width, $height] = getimagesize($file->getRealPath());
list($width, $height) = getimagesize($file->getRealPath());
$photo->update(['width' => $width, 'height' => $height]); $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([ return response()->json([
'message' => 'Upload successful. Awaiting moderation.', 'message' => 'Upload successful. Awaiting moderation.',
'photo_id' => $photo->id, 'photo_id' => $photo->id,

View 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);
}
}
}
}
}

View File

@@ -7,6 +7,9 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use App\Models\EventStorageAssignment;
use App\Models\EventMediaAsset;
use App\Models\MediaStorageTarget;
class Event extends Model class Event extends Model
{ {
@@ -21,6 +24,27 @@ class Event extends Model
'description' => 'array', '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 public function tenant(): BelongsTo
{ {
return $this->belongsTo(Tenant::class); return $this->belongsTo(Tenant::class);

View 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);
}
}

View 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');
}
}

View 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);
}
}

View File

@@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use App\Models\EventMediaAsset;
use Znck\Eloquent\Relations\BelongsToThrough as BelongsToThroughRelation; use Znck\Eloquent\Relations\BelongsToThrough as BelongsToThroughRelation;
use Znck\Eloquent\Traits\BelongsToThrough; use Znck\Eloquent\Traits\BelongsToThrough;
@@ -21,6 +22,11 @@ class Photo extends Model
'metadata' => 'array', 'metadata' => 'array',
]; ];
public function mediaAsset(): BelongsTo
{
return $this->belongsTo(EventMediaAsset::class, 'media_asset_id');
}
public function getImagePathAttribute(): ?string public function getImagePathAttribute(): ?string
{ {
return $this->file_path; return $this->file_path;

View 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());
}
}

View File

@@ -5,8 +5,14 @@ namespace App\Providers;
use App\Services\Checkout\CheckoutAssignmentService; use App\Services\Checkout\CheckoutAssignmentService;
use App\Services\Checkout\CheckoutPaymentService; use App\Services\Checkout\CheckoutPaymentService;
use App\Services\Checkout\CheckoutSessionService; 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\Cache\RateLimiting\Limit;
use Illuminate\Http\Request; 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\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Inertia\Inertia; use Inertia\Inertia;
@@ -21,6 +27,8 @@ class AppServiceProvider extends ServiceProvider
$this->app->singleton(CheckoutSessionService::class); $this->app->singleton(CheckoutSessionService::class);
$this->app->singleton(CheckoutAssignmentService::class); $this->app->singleton(CheckoutAssignmentService::class);
$this->app->singleton(CheckoutPaymentService::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 public function boot(): void
{ {
$this->app->make(EventStorageManager::class)->registerDynamicDisks();
RateLimiter::for('tenant-api', function (Request $request) { RateLimiter::for('tenant-api', function (Request $request) {
$tenantId = $request->attributes->get('tenant_id') $tenantId = $request->attributes->get('tenant_id')
?? $request->user()?->tenant_id ?? $request->user()?->tenant_id
@@ -42,12 +52,41 @@ class AppServiceProvider extends ServiceProvider
return Limit::perMinute(10)->by('oauth:' . ($request->ip() ?? 'unknown')); return Limit::perMinute(10)->by('oauth:' . ($request->ip() ?? 'unknown'));
}); });
\Inertia\Inertia::share('locale', function () { Inertia::share('locale', fn () => app()->getLocale());
return 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()) { if ($this->app->runningInConsole()) {
$this->app->register(\App\Providers\Filament\AdminPanelProvider::class); $this->app->register(\App\Providers\Filament\AdminPanelProvider::class);
} }
} }
} }

View File

@@ -52,6 +52,7 @@ class SuperAdminPanelProvider extends PanelProvider
Widgets\FilamentInfoWidget::class, Widgets\FilamentInfoWidget::class,
PlatformStatsWidget::class, PlatformStatsWidget::class,
TopTenantsByUploads::class, TopTenantsByUploads::class,
\App\Filament\Widgets\StorageCapacityWidget::class,
]) ])
->middleware([ ->middleware([
EncryptCookies::class, EncryptCookies::class,
@@ -73,6 +74,7 @@ class SuperAdminPanelProvider extends PanelProvider
\App\Filament\Resources\UserResource::class, \App\Filament\Resources\UserResource::class,
\App\Filament\Resources\TenantPackageResource::class, \App\Filament\Resources\TenantPackageResource::class,
\App\Filament\Resources\TaskResource::class, \App\Filament\Resources\TaskResource::class,
\App\Filament\Resources\MediaStorageTargetResource::class,
PostResource::class, PostResource::class,
CategoryResource::class, CategoryResource::class,
LegalPageResource::class, LegalPageResource::class,

View 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;
}
}

View 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,
];
}
}
}

View File

@@ -0,0 +1,10 @@
<?php
return [
'alert_recipients' => [
'mail' => env('STORAGE_ALERT_EMAIL'),
],
'queue_failure_alerts' => env('STORAGE_QUEUE_FAILURE_ALERTS', true),
];

View 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
View 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

View 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

View File

@@ -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');
}
};

View File

@@ -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');
}
};

View File

@@ -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');
}
};

View File

@@ -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');
});
}
};

View File

@@ -13,6 +13,7 @@ class DatabaseSeeder extends Seeder
{ {
// Seed basic system data // Seed basic system data
$this->call([ $this->call([
MediaStorageTargetSeeder::class,
LegalPagesSeeder::class, LegalPagesSeeder::class,
PackageSeeder::class, PackageSeeder::class,
]); ]);

View 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])
);
}
}
}

View File

@@ -14,37 +14,7 @@ class PackageSeeder extends Seeder
public function run(): void public function run(): void
{ {
$packages = [ $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', 'slug' => 'starter',
'name' => 'Starter', 'name' => 'Starter',
@@ -53,7 +23,7 @@ TEXT,
'en' => 'Starter', 'en' => 'Starter',
], ],
'type' => PackageType::ENDCUSTOMER, 'type' => PackageType::ENDCUSTOMER,
'price' => 59.00, 'price' => 29.00,
'max_photos' => 300, 'max_photos' => 300,
'max_guests' => 50, 'max_guests' => 50,
'gallery_days' => 14, 'gallery_days' => 14,
@@ -84,7 +54,7 @@ TEXT,
'en' => 'Standard', 'en' => 'Standard',
], ],
'type' => PackageType::ENDCUSTOMER, 'type' => PackageType::ENDCUSTOMER,
'price' => 129.00, 'price' => 59.00,
'max_photos' => 1000, 'max_photos' => 1000,
'max_guests' => 150, 'max_guests' => 150,
'gallery_days' => 30, 'gallery_days' => 30,
@@ -115,7 +85,7 @@ TEXT,
'en' => 'Premium', 'en' => 'Premium',
], ],
'type' => PackageType::ENDCUSTOMER, 'type' => PackageType::ENDCUSTOMER,
'price' => 249.00, 'price' => 129.00,
'max_photos' => 3000, 'max_photos' => 3000,
'max_guests' => 500, 'max_guests' => 500,
'gallery_days' => 180, 'gallery_days' => 180,
@@ -146,7 +116,7 @@ TEXT,
'en' => 'Reseller S', 'en' => 'Reseller S',
], ],
'type' => PackageType::RESELLER, 'type' => PackageType::RESELLER,
'price' => 299.00, 'price' => 149.00,
'max_photos' => 1000, 'max_photos' => 1000,
'max_guests' => null, 'max_guests' => null,
'gallery_days' => 30, 'gallery_days' => 30,
@@ -177,7 +147,7 @@ TEXT,
'en' => 'Reseller M', 'en' => 'Reseller M',
], ],
'type' => PackageType::RESELLER, 'type' => PackageType::RESELLER,
'price' => 599.00, 'price' => 349.00,
'max_photos' => 1500, 'max_photos' => 1500,
'max_guests' => null, 'max_guests' => null,
'gallery_days' => 60, 'gallery_days' => 60,
@@ -208,7 +178,7 @@ TEXT,
'en' => 'Reseller L', 'en' => 'Reseller L',
], ],
'type' => PackageType::RESELLER, 'type' => PackageType::RESELLER,
'price' => 1199.00, 'price' => 699.00,
'max_photos' => 3000, 'max_photos' => 3000,
'max_guests' => null, 'max_guests' => null,
'gallery_days' => 90, 'gallery_days' => 90,
@@ -239,7 +209,7 @@ TEXT,
'en' => 'Enterprise / Unlimited', 'en' => 'Enterprise / Unlimited',
], ],
'type' => PackageType::RESELLER, 'type' => PackageType::RESELLER,
'price' => 0.00, 'price' => 1999.00,
'max_photos' => null, 'max_photos' => null,
'max_guests' => null, 'max_guests' => null,
'gallery_days' => null, 'gallery_days' => null,

View File

@@ -6,3 +6,15 @@ use Illuminate\Support\Facades\Artisan;
Artisan::command('inspire', function () { Artisan::command('inspire', function () {
$this->comment(Inspiring::quote()); $this->comment(Inspiring::quote());
})->purpose('Display an 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');