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).
159 lines
4.2 KiB
PHP
159 lines
4.2 KiB
PHP
<?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;
|
|
}
|
|
}
|