added a help system, replaced the words "tenant" and "Pwa" with better alternatives. corrected and implemented cron jobs. prepared going live on a coolify-powered system.

This commit is contained in:
Codex Agent
2025-11-10 16:23:09 +01:00
parent ba9e64dfcb
commit 447a90a742
123 changed files with 6398 additions and 153 deletions

View File

@@ -0,0 +1,184 @@
<?php
namespace App\Console\Commands;
use App\Console\Concerns\InteractsWithCacheLocks;
use App\Models\EventMediaAsset;
use Illuminate\Console\Command;
use Illuminate\Contracts\Cache\Lock;
use Illuminate\Queue\QueueManager;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
class CheckUploadQueuesCommand extends Command
{
use InteractsWithCacheLocks;
protected $signature = 'storage:check-upload-queues {--force : Execute even if another health check is running}';
protected $description = 'Inspect upload-related queues and flag stalled or overloaded workers.';
public function handle(QueueManager $queueManager): int
{
$lockSeconds = (int) config('storage-monitor.queue_health.lock_seconds', 120);
$lock = $this->acquireCommandLock('storage:queue-health', $lockSeconds, (bool) $this->option('force'));
if ($lock === false) {
$this->warn('Queue health check already running.');
return self::SUCCESS;
}
$connection = config('queue.default');
$thresholds = config('storage-monitor.queue_health.thresholds', []);
if (empty($thresholds)) {
$thresholds = [
'default' => ['warning' => 100, 'critical' => 300],
'media-storage' => ['warning' => 200, 'critical' => 500],
'media-security' => ['warning' => 50, 'critical' => 150],
];
}
try {
$queueSummaries = [];
$alerts = [];
foreach ($thresholds as $queueName => $limits) {
$size = $this->readQueueSize($queueManager, $connection, (string) $queueName);
$failed = $this->countFailedJobs((string) $queueName);
$severity = $this->determineQueueSeverity($size, $limits);
if ($severity !== 'ok') {
$alerts[] = [
'queue' => $queueName,
'type' => 'size',
'severity' => $severity,
'size' => $size,
];
}
if ($failed > 0) {
$alerts[] = [
'queue' => $queueName,
'type' => 'failed_jobs',
'severity' => $failed >= 10 ? 'critical' : 'warning',
'failed' => $failed,
];
}
$queueSummaries[] = [
'queue' => $queueName,
'size' => $size,
'failed' => $failed,
'severity' => $severity,
'limits' => $limits,
];
}
$stalledMinutes = max(0, (int) config('storage-monitor.queue_health.stalled_minutes', 10));
$stalledAssets = 0;
if ($stalledMinutes > 0) {
$stalledAssets = EventMediaAsset::query()
->where('status', 'pending')
->where('created_at', '<=', now()->subMinutes($stalledMinutes))
->count();
if ($stalledAssets > 0) {
$alerts[] = [
'type' => 'pending_assets',
'severity' => 'warning',
'older_than_minutes' => $stalledMinutes,
'count' => $stalledAssets,
];
}
}
$snapshot = [
'generated_at' => now()->toIso8601String(),
'connection' => $connection,
'queues' => $queueSummaries,
'alerts' => $alerts,
'stalled_assets' => $stalledAssets,
];
$cacheTtl = max(1, (int) config('storage-monitor.queue_health.cache_minutes', 10));
Cache::put('storage:queue-health:last', $snapshot, now()->addMinutes($cacheTtl));
Log::channel('storage-jobs')->info('Upload queue health snapshot generated', [
'queues' => count($queueSummaries),
'alerts' => count($alerts),
]);
$this->info(sprintf(
'Checked %d queue(s); %d alert(s).',
count($queueSummaries),
count($alerts)
));
return self::SUCCESS;
} finally {
if ($lock instanceof Lock) {
$lock->release();
}
}
}
private function readQueueSize(QueueManager $manager, ?string $connection, string $queue): int
{
try {
return $manager->connection($connection)->size($queue);
} catch (\Throwable $exception) {
Log::channel('storage-jobs')->warning('Unable to read queue size', [
'queue' => $queue,
'connection' => $connection,
'message' => $exception->getMessage(),
]);
return -1;
}
}
private function countFailedJobs(string $queue): int
{
$table = config('queue.failed.table', 'failed_jobs');
if (! $this->failedJobsTableExists($table)) {
return 0;
}
return (int) DB::table($table)->where('queue', $queue)->count();
}
private function failedJobsTableExists(string $table): bool
{
static $cache = [];
if (array_key_exists($table, $cache)) {
return $cache[$table];
}
return $cache[$table] = Schema::hasTable($table);
}
private function determineQueueSeverity(int $size, array $limits): string
{
if ($size < 0) {
return 'unknown';
}
$critical = (int) ($limits['critical'] ?? 0);
$warning = (int) ($limits['warning'] ?? 0);
if ($critical > 0 && $size >= $critical) {
return 'critical';
}
if ($warning > 0 && $size >= $warning) {
return 'warning';
}
return 'ok';
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Console\Commands;
use App\Models\Event;
use App\Services\Photobooth\PhotoboothProvisioner;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class DeactivateExpiredPhotoboothAccounts extends Command
{
protected $signature = 'photobooth:cleanup-expired';
protected $description = 'Disable Photobooth FTP accounts that have passed their expiry date.';
public function handle(PhotoboothProvisioner $provisioner): int
{
$total = 0;
Event::query()
->where('photobooth_enabled', true)
->whereNotNull('photobooth_expires_at')
->where('photobooth_expires_at', '<=', now())
->chunkById(50, function ($events) use (&$total, $provisioner) {
foreach ($events as $event) {
try {
$provisioner->disable($event);
$total++;
} catch (\Throwable $exception) {
Log::error('Failed to disable expired photobooth account', [
'event_id' => $event->id,
'message' => $exception->getMessage(),
]);
}
}
});
$this->info(sprintf('Photobooth cleanup complete (%d accounts disabled).', $total));
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,106 @@
<?php
namespace App\Console\Commands;
use App\Console\Concerns\InteractsWithCacheLocks;
use App\Jobs\ArchiveEventMediaAssets;
use App\Models\Event;
use Illuminate\Console\Command;
use Illuminate\Contracts\Cache\Lock;
use Illuminate\Support\Facades\Log;
class DispatchStorageArchiveCommand extends Command
{
use InteractsWithCacheLocks;
protected $signature = 'storage:archive-pending
{--event= : Limit processing to a specific event ID}
{--force : Run even if another dispatcher instance is active}';
protected $description = 'Queue archive jobs for events whose galleries expired or were manually archived.';
public function handle(): int
{
$lockSeconds = (int) config('storage-monitor.archive.lock_seconds', 1800);
$lock = $this->acquireCommandLock('storage:archive-dispatcher', $lockSeconds, (bool) $this->option('force'));
if ($lock === false) {
$this->warn('Another archive dispatcher run is already executing.');
return self::SUCCESS;
}
$eventLockTtl = (int) config('storage-monitor.archive.event_lock_seconds', 3600);
$graceDays = max(0, (int) config('storage-monitor.archive.grace_days', 3));
$cutoff = now()->subDays($graceDays);
$chunkSize = max(1, (int) config('storage-monitor.archive.chunk', 25));
$maxDispatch = max(1, (int) config('storage-monitor.archive.max_dispatch', 100));
$eventId = $this->option('event');
$dispatched = 0;
try {
$query = Event::query()
->with('eventPackages:id,event_id,gallery_expires_at')
->whereHas('mediaAssets', function ($builder) {
$builder->where('status', '!=', 'archived');
});
if ($eventId) {
$query->whereKey($eventId);
} else {
$query->where(function ($builder) use ($cutoff) {
$builder->where('status', 'archived')
->orWhereHas('eventPackages', function ($packages) use ($cutoff) {
$packages->whereNotNull('gallery_expires_at')
->where('gallery_expires_at', '<=', $cutoff);
});
});
}
$query->chunkById($chunkSize, function ($events) use (&$dispatched, $maxDispatch, $eventLockTtl) {
foreach ($events as $event) {
if ($dispatched >= $maxDispatch) {
return false;
}
$eventLock = $this->acquireCommandLock('storage:archive-event-'.$event->id, $eventLockTtl);
if ($eventLock === false) {
Log::channel('storage-jobs')->info('Archive dispatch skipped due to in-flight lock', [
'event_id' => $event->id,
]);
continue;
}
try {
ArchiveEventMediaAssets::dispatch($event->id);
$dispatched++;
Log::channel('storage-jobs')->info('Archive job dispatched', [
'event_id' => $event->id,
'queue' => 'media-storage',
]);
} finally {
if ($eventLock instanceof Lock) {
$eventLock->release();
}
}
}
return null;
});
$this->info(sprintf('Dispatched %d archive job(s).', $dispatched));
Log::channel('storage-jobs')->info('Archive dispatch run finished', [
'dispatched' => $dispatched,
'event_limit' => $eventId,
]);
return self::SUCCESS;
} finally {
if ($lock instanceof Lock) {
$lock->release();
}
}
}
}

View File

@@ -0,0 +1,194 @@
<?php
namespace App\Console\Commands;
use App\Console\Concerns\InteractsWithCacheLocks;
use App\Models\EventMediaAsset;
use App\Models\MediaStorageTarget;
use App\Services\Storage\StorageHealthService;
use Illuminate\Console\Command;
use Illuminate\Contracts\Cache\Lock;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class MonitorStorageCommand extends Command
{
use InteractsWithCacheLocks;
protected $signature = 'storage:monitor {--force : Execute even if another run is still in progress}';
protected $description = 'Collect storage capacity statistics and cache them for dashboards & alerts.';
public function __construct(private readonly StorageHealthService $storageHealth)
{
parent::__construct();
}
public function handle(): int
{
$lockSeconds = (int) config('storage-monitor.monitor.lock_seconds', 300);
$lock = $this->acquireCommandLock('storage:monitor', $lockSeconds, (bool) $this->option('force'));
if ($lock === false) {
$this->warn('storage:monitor is already running on another worker.');
return self::SUCCESS;
}
try {
$targets = MediaStorageTarget::active()->get();
if ($targets->isEmpty()) {
$this->info('No media storage targets available.');
return self::SUCCESS;
}
$assetStats = $this->buildAssetStatistics();
$thresholds = $this->capacityThresholds();
$alerts = [];
$snapshotTargets = [];
foreach ($targets as $target) {
$capacity = $this->storageHealth->getCapacity($target);
$assets = $assetStats[$target->id] ?? [
'total' => 0,
'bytes' => 0,
'by_status' => [],
];
$severity = $this->determineCapacitySeverity($capacity, $thresholds);
if ($severity !== 'ok') {
$alerts[] = [
'target' => $target->key,
'type' => 'capacity',
'severity' => $severity,
'percentage' => $capacity['percentage'] ?? null,
'status' => $capacity['status'] ?? null,
];
}
$failedCount = $assets['by_status']['failed']['count'] ?? 0;
if ($failedCount > 0) {
$alerts[] = [
'target' => $target->key,
'type' => 'failed_assets',
'severity' => 'warning',
'failed' => $failedCount,
];
}
$snapshotTargets[] = [
'id' => $target->id,
'key' => $target->key,
'name' => $target->name,
'is_hot' => (bool) $target->is_hot,
'capacity' => $capacity,
'assets' => $assets,
];
}
$snapshot = [
'generated_at' => now()->toIso8601String(),
'targets' => $snapshotTargets,
'alerts' => $alerts,
];
$ttlMinutes = max(1, (int) config('storage-monitor.monitor.cache_minutes', 15));
Cache::put('storage:monitor:last', $snapshot, now()->addMinutes($ttlMinutes));
Log::channel('storage-jobs')->info('Storage monitor snapshot generated', [
'targets' => count($snapshotTargets),
'alerts' => count($alerts),
]);
$this->info(sprintf(
'Storage monitor finished: %d targets, %d alerts.',
count($snapshotTargets),
count($alerts)
));
return self::SUCCESS;
} finally {
if ($lock instanceof Lock) {
$lock->release();
}
}
}
/**
* @return array<int, array>
*/
private function buildAssetStatistics(): array
{
return EventMediaAsset::query()
->selectRaw('media_storage_target_id, status, COUNT(*) as total_count, COALESCE(SUM(size_bytes), 0) as total_bytes')
->groupBy('media_storage_target_id', 'status')
->get()
->groupBy('media_storage_target_id')
->map(function ($rows) {
$byStatus = [];
$totalCount = 0;
$totalBytes = 0;
foreach ($rows as $row) {
$count = (int) ($row->total_count ?? 0);
$bytes = (int) ($row->total_bytes ?? 0);
$totalCount += $count;
$totalBytes += $bytes;
$byStatus[$row->status] = [
'count' => $count,
'bytes' => $bytes,
];
}
ksort($byStatus);
return [
'total' => $totalCount,
'bytes' => $totalBytes,
'by_status' => $byStatus,
];
})
->all();
}
private function capacityThresholds(): array
{
$warning = (int) config('storage-monitor.capacity_thresholds.warning', 75);
$critical = (int) config('storage-monitor.capacity_thresholds.critical', 90);
if ($warning > $critical) {
[$warning, $critical] = [$critical, $warning];
}
return [
'warning' => $warning,
'critical' => $critical,
];
}
private function determineCapacitySeverity(array $capacity, array $thresholds): string
{
$status = $capacity['status'] ?? 'ok';
if ($status !== 'ok') {
return $status;
}
$percentage = $capacity['percentage'] ?? null;
if ($percentage === null) {
return 'unknown';
}
if ($percentage >= ($thresholds['critical'] ?? 95)) {
return 'critical';
}
if ($percentage >= ($thresholds['warning'] ?? 75)) {
return 'warning';
}
return 'ok';
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Console\Commands;
use App\Models\Event;
use App\Services\Photobooth\PhotoboothIngestService;
use Illuminate\Console\Command;
class PhotoboothIngestCommand extends Command
{
protected $signature = 'photobooth:ingest {--event= : Restrict ingestion to a single event ID}
{--max-files= : Maximum files to import per event in this run}';
protected $description = 'Ingest pending Photobooth uploads from the FTP mount into event storage.';
public function handle(PhotoboothIngestService $ingestService): int
{
$eventId = $this->option('event');
$maxFiles = $this->option('max-files');
$processedTotal = 0;
$skippedTotal = 0;
$query = Event::query()
->where('photobooth_enabled', true)
->whereNotNull('photobooth_path');
if ($eventId) {
$query->whereKey($eventId);
}
$query->chunkById(25, function ($events) use ($ingestService, $maxFiles, &$processedTotal, &$skippedTotal) {
foreach ($events as $event) {
$summary = $ingestService->ingest($event, $maxFiles ? (int) $maxFiles : null);
$processedTotal += $summary['processed'] ?? 0;
$skippedTotal += $summary['skipped'] ?? 0;
$this->line(sprintf(
'Event #%d (%s): %d imported, %d skipped',
$event->id,
$event->slug ?? 'event',
$summary['processed'] ?? 0,
$summary['skipped'] ?? 0
));
}
});
$this->info(sprintf('Photobooth ingest finished. Processed: %d, Skipped: %d', $processedTotal, $skippedTotal));
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Console\Commands;
use App\Services\Help\HelpSyncService;
use Illuminate\Console\Command;
class SyncHelpCenter extends Command
{
protected $signature = 'help:sync';
protected $description = 'Compile markdown help articles into cached JSON bundles.';
public function handle(HelpSyncService $service): int
{
$result = $service->sync();
foreach ($result as $audience => $locales) {
foreach ($locales as $locale => $count) {
$this->components->info(sprintf('Synced %d %s/%s articles', $count, $audience, $locale));
}
}
$this->components->success('Help center cache updated.');
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Console\Concerns;
use Illuminate\Contracts\Cache\Lock;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
trait InteractsWithCacheLocks
{
/**
* Attempt to acquire a cache-backed lock for the given command.
*
* @return \Illuminate\Contracts\Cache\Lock|false|null Returns the lock when acquired, false if another process holds it, null if locks are unsupported.
*/
protected function acquireCommandLock(string $name, int $seconds, bool $force = false): Lock|false|null
{
try {
$lock = Cache::lock($name, $seconds);
if ($force) {
$lock->forceRelease();
}
if ($lock->get()) {
return $lock;
}
return false;
} catch (\BadMethodCallException $exception) {
Log::channel('storage-jobs')->debug('Cache store does not support locks for command', [
'lock' => $name,
'store' => config('cache.default'),
'exception' => $exception->getMessage(),
]);
return null;
}
}
}