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:
22
.env.example
22
.env.example
@@ -116,4 +116,26 @@ SECURITY_AV_TIMEOUT=60
|
||||
SECURITY_STRIP_EXIF=true
|
||||
SECURITY_SCAN_QUEUE=media-security
|
||||
|
||||
# Photobooth / FTP ingestion
|
||||
PHOTOBOOTH_CONTROL_BASE_URL=
|
||||
PHOTOBOOTH_CONTROL_TOKEN=
|
||||
PHOTOBOOTH_CONTROL_TIMEOUT=5
|
||||
PHOTOBOOTH_FTP_HOST=ftp.internal
|
||||
PHOTOBOOTH_FTP_PORT=2121
|
||||
PHOTOBOOTH_USERNAME_PREFIX=pb
|
||||
PHOTOBOOTH_USERNAME_LENGTH=8
|
||||
PHOTOBOOTH_PASSWORD_LENGTH=8
|
||||
PHOTOBOOTH_RATE_LIMIT_PER_MINUTE=20
|
||||
PHOTOBOOTH_EXPIRY_GRACE_DAYS=1
|
||||
PHOTOBOOTH_IMPORT_DISK=photobooth
|
||||
PHOTOBOOTH_IMPORT_ROOT=/var/www/storage/app/photobooth
|
||||
PHOTOBOOTH_IMPORT_MAX_FILES=50
|
||||
PHOTOBOOTH_ALLOWED_EXTENSIONS=jpg,jpeg,png,webp
|
||||
|
||||
COOLIFY_API_BASE_URL=
|
||||
COOLIFY_API_TOKEN=
|
||||
COOLIFY_WEB_URL=
|
||||
COOLIFY_API_TIMEOUT=5
|
||||
COOLIFY_SERVICE_IDS={"app":"svc_app","queue":"svc_queue","scheduler":"svc_scheduler","ftp":"svc_ftp","control":"svc_control"}
|
||||
|
||||
|
||||
|
||||
184
app/Console/Commands/CheckUploadQueuesCommand.php
Normal file
184
app/Console/Commands/CheckUploadQueuesCommand.php
Normal 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';
|
||||
}
|
||||
}
|
||||
42
app/Console/Commands/DeactivateExpiredPhotoboothAccounts.php
Normal file
42
app/Console/Commands/DeactivateExpiredPhotoboothAccounts.php
Normal 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;
|
||||
}
|
||||
}
|
||||
106
app/Console/Commands/DispatchStorageArchiveCommand.php
Normal file
106
app/Console/Commands/DispatchStorageArchiveCommand.php
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
194
app/Console/Commands/MonitorStorageCommand.php
Normal file
194
app/Console/Commands/MonitorStorageCommand.php
Normal 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';
|
||||
}
|
||||
}
|
||||
51
app/Console/Commands/PhotoboothIngestCommand.php
Normal file
51
app/Console/Commands/PhotoboothIngestCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
28
app/Console/Commands/SyncHelpCenter.php
Normal file
28
app/Console/Commands/SyncHelpCenter.php
Normal 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;
|
||||
}
|
||||
}
|
||||
40
app/Console/Concerns/InteractsWithCacheLocks.php
Normal file
40
app/Console/Concerns/InteractsWithCacheLocks.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\CoolifyActionLogs;
|
||||
|
||||
use App\Filament\Resources\CoolifyActionLogs\Pages\ManageCoolifyActionLogs;
|
||||
use App\Models\CoolifyActionLog;
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use UnitEnum;
|
||||
|
||||
class CoolifyActionLogResource extends Resource
|
||||
{
|
||||
protected static ?string $model = CoolifyActionLog::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedRectangleStack;
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Platform';
|
||||
|
||||
protected static ?int $navigationSort = 90;
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('created_at')
|
||||
->label('Timestamp')
|
||||
->sortable()
|
||||
->dateTime(),
|
||||
Tables\Columns\TextColumn::make('user.name')
|
||||
->label('User')
|
||||
->sortable()
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('service_id')
|
||||
->label('Service')
|
||||
->searchable()
|
||||
->copyable()
|
||||
->limit(30),
|
||||
Tables\Columns\BadgeColumn::make('action')
|
||||
->label('Action')
|
||||
->colors([
|
||||
'warning' => 'restart',
|
||||
'info' => 'redeploy',
|
||||
'gray' => 'logs',
|
||||
])
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('status_code')
|
||||
->label('HTTP')
|
||||
->sortable(),
|
||||
])
|
||||
->filters([
|
||||
//
|
||||
])
|
||||
->recordActions([
|
||||
Tables\Actions\ViewAction::make(),
|
||||
])
|
||||
->toolbarActions([
|
||||
//
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ManageCoolifyActionLogs::route('/'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\CoolifyActionLogs\Pages;
|
||||
|
||||
use App\Filament\Resources\CoolifyActionLogs\CoolifyActionLogResource;
|
||||
use Filament\Resources\Pages\ManageRecords;
|
||||
|
||||
class ManageCoolifyActionLogs extends ManageRecords
|
||||
{
|
||||
protected static string $resource = CoolifyActionLogResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\PhotoboothSettings\Pages;
|
||||
|
||||
use App\Filament\Resources\PhotoboothSettings\PhotoboothSettingResource;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditPhotoboothSetting extends EditRecord
|
||||
{
|
||||
protected static string $resource = PhotoboothSettingResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
protected function getRedirectUrl(): string
|
||||
{
|
||||
return $this->getResource()::getUrl('index');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\PhotoboothSettings\Pages;
|
||||
|
||||
use App\Filament\Resources\PhotoboothSettings\PhotoboothSettingResource;
|
||||
use App\Models\PhotoboothSetting;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListPhotoboothSettings extends ListRecords
|
||||
{
|
||||
protected static string $resource = PhotoboothSettingResource::class;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
parent::mount();
|
||||
|
||||
PhotoboothSetting::current();
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\PhotoboothSettings;
|
||||
|
||||
use App\Filament\Resources\PhotoboothSettings\Pages\EditPhotoboothSetting;
|
||||
use App\Filament\Resources\PhotoboothSettings\Pages\ListPhotoboothSettings;
|
||||
use App\Filament\Resources\PhotoboothSettings\Schemas\PhotoboothSettingForm;
|
||||
use App\Filament\Resources\PhotoboothSettings\Schemas\PhotoboothSettingInfolist;
|
||||
use App\Filament\Resources\PhotoboothSettings\Tables\PhotoboothSettingsTable;
|
||||
use App\Models\PhotoboothSetting;
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use UnitEnum;
|
||||
|
||||
class PhotoboothSettingResource extends Resource
|
||||
{
|
||||
protected static ?string $model = PhotoboothSetting::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedRectangleStack;
|
||||
|
||||
protected static UnitEnum|string|null $navigationGroup = null;
|
||||
|
||||
protected static ?int $navigationSort = 95;
|
||||
|
||||
public static function getNavigationGroup(): UnitEnum|string|null
|
||||
{
|
||||
return __('admin.nav.platform_management');
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return PhotoboothSettingForm::configure($schema);
|
||||
}
|
||||
|
||||
public static function infolist(Schema $schema): Schema
|
||||
{
|
||||
return PhotoboothSettingInfolist::configure($schema);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return PhotoboothSettingsTable::configure($table);
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListPhotoboothSettings::route('/'),
|
||||
'edit' => EditPhotoboothSetting::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
|
||||
public static function canCreate(?Model $record = null): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function canDelete(?Model $record = null): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function canDeleteAny(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\PhotoboothSettings\Schemas;
|
||||
|
||||
use Filament\Forms\Components\Section;
|
||||
use Filament\Forms\Components\TagsInput;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class PhotoboothSettingForm
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema->components([
|
||||
Section::make(__('FTP-Verbindung'))
|
||||
->description(__('Globale Parameter für den vsftpd-Container.'))
|
||||
->schema([
|
||||
TextInput::make('ftp_port')
|
||||
->numeric()
|
||||
->required()
|
||||
->minValue(1)
|
||||
->maxValue(65535)
|
||||
->helperText(__('Standard: Port 2121 innerhalb des internen Netzwerks.')),
|
||||
TextInput::make('rate_limit_per_minute')
|
||||
->label(__('Uploads pro Minute'))
|
||||
->numeric()
|
||||
->required()
|
||||
->minValue(1)
|
||||
->maxValue(200)
|
||||
->helperText(__('Harte Rate-Limits für Photobooth-Clients.')),
|
||||
TextInput::make('expiry_grace_days')
|
||||
->label(__('Ablauf (Tage nach Eventende)'))
|
||||
->numeric()
|
||||
->required()
|
||||
->minValue(0)
|
||||
->maxValue(14),
|
||||
])->columns(3),
|
||||
Section::make(__('Sicherheit & Steuerung'))
|
||||
->schema([
|
||||
Toggle::make('require_ftps')
|
||||
->label(__('FTPS erzwingen'))
|
||||
->helperText(__('Aktivieren, wenn nur verschlüsselte FTP-Verbindungen erlaubt sein sollen.')),
|
||||
TagsInput::make('allowed_ip_ranges')
|
||||
->label(__('Erlaubte IP-Ranges (optional)'))
|
||||
->placeholder('10.0.0.0/24')
|
||||
->helperText(__('Liste optionaler CIDR-Ranges für Control-Service Allowlisting.')),
|
||||
TextInput::make('control_service_base_url')
|
||||
->label(__('Control-Service URL'))
|
||||
->url()
|
||||
->maxLength(191)
|
||||
->helperText(__('REST-Endpunkt des Provisioning-Sidecars (z. B. http://control:8080).')),
|
||||
TextInput::make('control_service_token_identifier')
|
||||
->label(__('Token Referenz'))
|
||||
->maxLength(191)
|
||||
->helperText(__('Bezeichner des Secrets im Secrets-Store (keine Klartext-Tokens speichern).')),
|
||||
])->columns(2),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\PhotoboothSettings\Schemas;
|
||||
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class PhotoboothSettingInfolist
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
//
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\PhotoboothSettings\Tables;
|
||||
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class PhotoboothSettingsTable
|
||||
{
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('ftp_port')
|
||||
->label(__('Port'))
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('rate_limit_per_minute')
|
||||
->label(__('Uploads/Minute'))
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('expiry_grace_days')
|
||||
->label(__('Ablauf +Tage'))
|
||||
->sortable(),
|
||||
Tables\Columns\IconColumn::make('require_ftps')
|
||||
->label(__('FTPS'))
|
||||
->boolean(),
|
||||
Tables\Columns\TextColumn::make('updated_at')
|
||||
->since()
|
||||
->label(__('Aktualisiert')),
|
||||
])
|
||||
->recordActions([
|
||||
Tables\Actions\EditAction::make(),
|
||||
])
|
||||
->headerActions([])
|
||||
->bulkActions([]);
|
||||
}
|
||||
}
|
||||
127
app/Filament/SuperAdmin/Pages/CoolifyDeployments.php
Normal file
127
app/Filament/SuperAdmin/Pages/CoolifyDeployments.php
Normal file
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\SuperAdmin\Pages;
|
||||
|
||||
use App\Models\CoolifyActionLog;
|
||||
use App\Services\Coolify\CoolifyClient;
|
||||
use BackedEnum;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class CoolifyDeployments extends Page
|
||||
{
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-wrench-screwdriver';
|
||||
|
||||
protected static ?string $navigationLabel = 'Infrastructure';
|
||||
|
||||
protected static ?string $title = 'Infrastructure Controls';
|
||||
|
||||
protected string $view = 'filament.super-admin.pages.coolify-deployments';
|
||||
|
||||
public array $services = [];
|
||||
|
||||
public array $recentLogs = [];
|
||||
|
||||
public ?string $coolifyWebUrl = null;
|
||||
|
||||
public function mount(CoolifyClient $client): void
|
||||
{
|
||||
$this->coolifyWebUrl = config('coolify.web_url');
|
||||
$this->refreshServices($client);
|
||||
$this->refreshLogs();
|
||||
}
|
||||
|
||||
public function restart(string $serviceId): void
|
||||
{
|
||||
$this->performAction($serviceId, 'restart');
|
||||
}
|
||||
|
||||
public function redeploy(string $serviceId): void
|
||||
{
|
||||
$this->performAction($serviceId, 'redeploy');
|
||||
}
|
||||
|
||||
protected function performAction(string $serviceId, string $action): void
|
||||
{
|
||||
$client = app(CoolifyClient::class);
|
||||
|
||||
if (! $this->isKnownService($serviceId)) {
|
||||
Notification::make()
|
||||
->danger()
|
||||
->title('Unknown service')
|
||||
->body("The service ID {$serviceId} is not configured.")
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$action === 'restart'
|
||||
? $client->restartService($serviceId, auth()->user())
|
||||
: $client->redeployService($serviceId, auth()->user());
|
||||
|
||||
Notification::make()
|
||||
->success()
|
||||
->title(ucfirst($action).' requested')
|
||||
->body("Coolify accepted the {$action} action for {$serviceId}.")
|
||||
->send();
|
||||
} catch (\Throwable $exception) {
|
||||
Notification::make()
|
||||
->danger()
|
||||
->title('Coolify request failed')
|
||||
->body($exception->getMessage())
|
||||
->send();
|
||||
}
|
||||
|
||||
$this->refreshServices($client);
|
||||
$this->refreshLogs();
|
||||
}
|
||||
|
||||
protected function refreshServices(CoolifyClient $client): void
|
||||
{
|
||||
$serviceMap = config('coolify.services', []);
|
||||
$results = [];
|
||||
|
||||
foreach ($serviceMap as $label => $id) {
|
||||
try {
|
||||
$status = $client->serviceStatus($id);
|
||||
$results[] = [
|
||||
'label' => ucfirst($label),
|
||||
'service_id' => $id,
|
||||
'status' => Arr::get($status, 'data.status', 'unknown'),
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
$results[] = [
|
||||
'label' => ucfirst($label),
|
||||
'service_id' => $id,
|
||||
'status' => 'error',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$this->services = $results;
|
||||
}
|
||||
|
||||
protected function refreshLogs(): void
|
||||
{
|
||||
$this->recentLogs = CoolifyActionLog::query()
|
||||
->with('user')
|
||||
->latest()
|
||||
->limit(5)
|
||||
->get()
|
||||
->map(fn ($log) => [
|
||||
'created_at' => $log->created_at->diffForHumans(),
|
||||
'user' => $log->user?->name ?? 'System',
|
||||
'service_id' => $log->service_id,
|
||||
'action' => $log->action,
|
||||
'status_code' => $log->status_code,
|
||||
])
|
||||
->toArray();
|
||||
}
|
||||
|
||||
protected function isKnownService(string $serviceId): bool
|
||||
{
|
||||
return in_array($serviceId, array_values(config('coolify.services', [])), true);
|
||||
}
|
||||
}
|
||||
62
app/Filament/Widgets/CoolifyPlatformHealth.php
Normal file
62
app/Filament/Widgets/CoolifyPlatformHealth.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Widgets;
|
||||
|
||||
use App\Services\Coolify\CoolifyClient;
|
||||
use Filament\Widgets\Widget;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class CoolifyPlatformHealth extends Widget
|
||||
{
|
||||
protected string $view = 'filament.widgets.coolify-platform-health';
|
||||
|
||||
protected ?string $pollingInterval = '60s';
|
||||
|
||||
protected function getViewData(): array
|
||||
{
|
||||
return [
|
||||
'services' => $this->loadServices(),
|
||||
];
|
||||
}
|
||||
|
||||
protected function loadServices(): array
|
||||
{
|
||||
$client = app(CoolifyClient::class);
|
||||
$serviceMap = config('coolify.services', []);
|
||||
$results = [];
|
||||
|
||||
foreach ($serviceMap as $label => $serviceId) {
|
||||
try {
|
||||
$status = $client->serviceStatus($serviceId);
|
||||
$results[] = [
|
||||
'label' => ucfirst($label),
|
||||
'service_id' => $serviceId,
|
||||
'status' => Arr::get($status, 'data.status', 'unknown'),
|
||||
'cpu' => Arr::get($status, 'data.metrics.cpu_percent'),
|
||||
'memory' => Arr::get($status, 'data.metrics.memory_percent'),
|
||||
'last_deploy' => Arr::get($status, 'data.last_deployment.finished_at'),
|
||||
];
|
||||
} catch (\Throwable $exception) {
|
||||
$results[] = [
|
||||
'label' => ucfirst($label),
|
||||
'service_id' => $serviceId,
|
||||
'status' => 'unreachable',
|
||||
'error' => $exception->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($results)) {
|
||||
return [
|
||||
[
|
||||
'label' => 'Coolify',
|
||||
'service_id' => '-',
|
||||
'status' => 'unconfigured',
|
||||
'error' => 'Set COOLIFY_SERVICE_IDS in .env to enable monitoring.',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
}
|
||||
@@ -1251,6 +1251,8 @@ class EventPublicController extends BaseController
|
||||
'photos.emotion_id',
|
||||
'photos.task_id',
|
||||
'photos.guest_name',
|
||||
'photos.created_at',
|
||||
'photos.ingest_source',
|
||||
'tasks.title as task_title',
|
||||
])
|
||||
->where('photos.event_id', $eventId)
|
||||
@@ -1258,7 +1260,9 @@ class EventPublicController extends BaseController
|
||||
->limit(60);
|
||||
|
||||
// MyPhotos filter
|
||||
if ($filter === 'myphotos' && $deviceId !== 'anon') {
|
||||
if ($filter === 'photobooth') {
|
||||
$query->where('photos.ingest_source', Photo::SOURCE_PHOTOBOOTH);
|
||||
} elseif ($filter === 'myphotos' && $deviceId !== 'anon') {
|
||||
$query->where('guest_name', $deviceId);
|
||||
}
|
||||
|
||||
@@ -1276,6 +1280,8 @@ class EventPublicController extends BaseController
|
||||
$r->task_title = $this->getLocalized($r->task_title, $locale, 'Unbenannte Aufgabe');
|
||||
}
|
||||
|
||||
$r->ingest_source = $r->ingest_source ?? Photo::SOURCE_UNKNOWN;
|
||||
|
||||
return $r;
|
||||
});
|
||||
$latestPhotoAt = DB::table('photos')->where('event_id', $eventId)->max('created_at');
|
||||
@@ -1495,6 +1501,7 @@ class EventPublicController extends BaseController
|
||||
'file_path' => $url,
|
||||
'thumbnail_path' => $thumbUrl,
|
||||
'likes_count' => 0,
|
||||
'ingest_source' => Photo::SOURCE_GUEST_PWA,
|
||||
|
||||
// Handle emotion_id: prefer explicit ID, fallback to slug lookup, then default
|
||||
'emotion_id' => $this->resolveEmotionId($validated, $eventId),
|
||||
|
||||
129
app/Http/Controllers/Api/HelpController.php
Normal file
129
app/Http/Controllers/Api/HelpController.php
Normal file
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Support\Help\HelpRepository;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Sanctum\PersonalAccessToken;
|
||||
use RuntimeException;
|
||||
|
||||
class HelpController extends Controller
|
||||
{
|
||||
public function __construct(private readonly HelpRepository $repository) {}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
[$audience, $locale] = $this->resolveContext($request);
|
||||
|
||||
$articles = $this->getArticles($audience, $locale)
|
||||
->map(fn ($article) => Arr::only($article, config('help.list_fields')))
|
||||
->values();
|
||||
|
||||
return response()->json([
|
||||
'data' => $articles,
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(Request $request, string $slug): JsonResponse
|
||||
{
|
||||
[$audience, $locale] = $this->resolveContext($request);
|
||||
|
||||
$article = $this->getArticle($audience, $locale, $slug);
|
||||
|
||||
abort_if(! $article, 404, 'Help article not found.');
|
||||
|
||||
return response()->json([
|
||||
'data' => $article,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{string, string}
|
||||
*/
|
||||
private function resolveContext(Request $request): array
|
||||
{
|
||||
$this->attemptTokenAuthentication($request);
|
||||
|
||||
$audience = Str::of($request->string('audience', 'guest'))->lower()->value();
|
||||
$locale = Str::of($request->string('locale', config('help.default_locale')))->lower()->value();
|
||||
|
||||
if ($audience === 'admin' && ! $request->user()) {
|
||||
abort(401, 'Authentication required for admin help content.');
|
||||
}
|
||||
|
||||
if (! in_array($audience, config('help.audiences', []), true)) {
|
||||
abort(400, 'Invalid audience supplied.');
|
||||
}
|
||||
|
||||
return [$audience, $locale];
|
||||
}
|
||||
|
||||
private function attemptTokenAuthentication(Request $request): void
|
||||
{
|
||||
if ($request->user()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$bearer = $request->bearerToken();
|
||||
|
||||
if (! $bearer) {
|
||||
return;
|
||||
}
|
||||
|
||||
$token = PersonalAccessToken::findToken($bearer);
|
||||
|
||||
if (! $token) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user = $token->tokenable;
|
||||
|
||||
if (! $user) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (method_exists($user, 'withAccessToken')) {
|
||||
$user->withAccessToken($token);
|
||||
}
|
||||
|
||||
Auth::setUser($user);
|
||||
$request->setUserResolver(fn () => $user);
|
||||
}
|
||||
|
||||
private function getArticles(string $audience, string $locale)
|
||||
{
|
||||
try {
|
||||
return $this->repository->list($audience, $locale);
|
||||
} catch (RuntimeException $e) {
|
||||
$fallback = config('help.fallback_locale');
|
||||
|
||||
if ($locale === $fallback) {
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return $this->repository->list($audience, $fallback);
|
||||
}
|
||||
}
|
||||
|
||||
private function getArticle(string $audience, string $locale, string $slug): ?array
|
||||
{
|
||||
try {
|
||||
$article = $this->repository->find($audience, $locale, $slug);
|
||||
} catch (RuntimeException $e) {
|
||||
$fallback = config('help.fallback_locale');
|
||||
|
||||
if ($locale !== $fallback) {
|
||||
return $this->repository->find($audience, $fallback, $slug);
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return $article;
|
||||
}
|
||||
}
|
||||
@@ -153,14 +153,12 @@ class PhotoController extends Controller
|
||||
$thumbnailPath = $thumbnailRelative;
|
||||
}
|
||||
|
||||
// Create photo record
|
||||
$photo = Photo::create([
|
||||
$photoAttributes = [
|
||||
'event_id' => $event->id,
|
||||
'filename' => $filename,
|
||||
'original_name' => $file->getClientOriginalName(),
|
||||
'mime_type' => $file->getMimeType(),
|
||||
'size' => $file->getSize(),
|
||||
'path' => $path,
|
||||
'file_path' => $path,
|
||||
'thumbnail_path' => $thumbnailPath,
|
||||
'width' => null, // Filled below
|
||||
'height' => null,
|
||||
@@ -168,7 +166,17 @@ class PhotoController extends Controller
|
||||
'uploader_id' => null,
|
||||
'ip_address' => $request->ip(),
|
||||
'user_agent' => $request->userAgent(),
|
||||
]);
|
||||
'ingest_source' => Photo::SOURCE_TENANT_ADMIN,
|
||||
];
|
||||
|
||||
if (Photo::supportsFilenameColumn()) {
|
||||
$photoAttributes['filename'] = $filename;
|
||||
}
|
||||
if (Photo::hasColumn('path')) {
|
||||
$photoAttributes['path'] = $path;
|
||||
}
|
||||
|
||||
$photo = Photo::create($photoAttributes);
|
||||
|
||||
// Record primary asset metadata
|
||||
$checksum = hash_file('sha256', $file->getRealPath());
|
||||
@@ -663,19 +671,27 @@ class PhotoController extends Controller
|
||||
$thumbnailPath = $thumbnailRelative;
|
||||
}
|
||||
|
||||
// Create photo record
|
||||
$photo = Photo::create([
|
||||
$photoAttributes = [
|
||||
'event_id' => $event->id,
|
||||
'filename' => $filename,
|
||||
'original_name' => $request->original_name,
|
||||
'mime_type' => $file->getMimeType(),
|
||||
'size' => $file->getSize(),
|
||||
'path' => $path,
|
||||
'file_path' => $path,
|
||||
'thumbnail_path' => $thumbnailPath,
|
||||
'status' => 'pending',
|
||||
'ip_address' => $request->ip(),
|
||||
'user_agent' => $request->userAgent(),
|
||||
]);
|
||||
'ingest_source' => Photo::SOURCE_TENANT_ADMIN,
|
||||
];
|
||||
|
||||
if (Photo::supportsFilenameColumn()) {
|
||||
$photoAttributes['filename'] = $filename;
|
||||
}
|
||||
if (Photo::hasColumn('path')) {
|
||||
$photoAttributes['path'] = $path;
|
||||
}
|
||||
|
||||
$photo = Photo::create($photoAttributes);
|
||||
|
||||
[$width, $height] = getimagesize($file->getRealPath());
|
||||
$photo->update(['width' => $width, 'height' => $height]);
|
||||
|
||||
79
app/Http/Controllers/Api/Tenant/PhotoboothController.php
Normal file
79
app/Http/Controllers/Api/Tenant/PhotoboothController.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Tenant;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\Tenant\PhotoboothStatusResource;
|
||||
use App\Models\Event;
|
||||
use App\Models\PhotoboothSetting;
|
||||
use App\Services\Photobooth\PhotoboothProvisioner;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class PhotoboothController extends Controller
|
||||
{
|
||||
public function __construct(private readonly PhotoboothProvisioner $provisioner) {}
|
||||
|
||||
public function show(Request $request, Event $event): PhotoboothStatusResource
|
||||
{
|
||||
$this->assertEventBelongsToTenant($request, $event);
|
||||
|
||||
return $this->resource($event);
|
||||
}
|
||||
|
||||
public function enable(Request $request, Event $event): JsonResponse
|
||||
{
|
||||
$this->assertEventBelongsToTenant($request, $event);
|
||||
|
||||
$event->loadMissing('tenant');
|
||||
$updated = $this->provisioner->enable($event);
|
||||
|
||||
return response()->json([
|
||||
'message' => __('Photobooth-Zugang aktiviert.'),
|
||||
'data' => $this->resource($updated),
|
||||
]);
|
||||
}
|
||||
|
||||
public function rotate(Request $request, Event $event): JsonResponse
|
||||
{
|
||||
$this->assertEventBelongsToTenant($request, $event);
|
||||
|
||||
$event->loadMissing('tenant');
|
||||
$updated = $this->provisioner->rotate($event);
|
||||
|
||||
return response()->json([
|
||||
'message' => __('Zugangsdaten neu generiert.'),
|
||||
'data' => $this->resource($updated),
|
||||
]);
|
||||
}
|
||||
|
||||
public function disable(Request $request, Event $event): JsonResponse
|
||||
{
|
||||
$this->assertEventBelongsToTenant($request, $event);
|
||||
|
||||
$event->loadMissing('tenant');
|
||||
$updated = $this->provisioner->disable($event);
|
||||
|
||||
return response()->json([
|
||||
'message' => __('Photobooth-Zugang deaktiviert.'),
|
||||
'data' => $this->resource($updated),
|
||||
]);
|
||||
}
|
||||
|
||||
protected function resource(Event $event): PhotoboothStatusResource
|
||||
{
|
||||
return PhotoboothStatusResource::make([
|
||||
'event' => $event->fresh(),
|
||||
'settings' => PhotoboothSetting::current(),
|
||||
]);
|
||||
}
|
||||
|
||||
protected function assertEventBelongsToTenant(Request $request, Event $event): void
|
||||
{
|
||||
$tenantId = (int) $request->attributes->get('tenant_id');
|
||||
|
||||
if ($tenantId !== (int) $event->tenant_id) {
|
||||
abort(403, 'Event gehört nicht zu diesem Tenant.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ class PhotoResource extends JsonResource
|
||||
'is_liked' => false,
|
||||
'uploaded_at' => $this->created_at->toISOString(),
|
||||
'uploader_name' => $this->guest_name ?? null,
|
||||
'ingest_source' => $this->ingest_source,
|
||||
'event' => [
|
||||
'id' => $this->event->id,
|
||||
'name' => $this->event->name,
|
||||
|
||||
76
app/Http/Resources/Tenant/PhotoboothStatusResource.php
Normal file
76
app/Http/Resources/Tenant/PhotoboothStatusResource.php
Normal file
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources\Tenant;
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\PhotoboothSetting;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class PhotoboothStatusResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
$payload = $this->resolvePayload();
|
||||
/** @var Event $event */
|
||||
$event = $payload['event'];
|
||||
/** @var PhotoboothSetting $settings */
|
||||
$settings = $payload['settings'];
|
||||
|
||||
$password = $event->getAttribute('plain_photobooth_password') ?? $event->photobooth_password;
|
||||
|
||||
return [
|
||||
'enabled' => (bool) $event->photobooth_enabled,
|
||||
'status' => $event->photobooth_status,
|
||||
'username' => $event->photobooth_username,
|
||||
'password' => $password,
|
||||
'path' => $event->photobooth_path,
|
||||
'ftp_url' => $this->buildFtpUrl($event, $settings, $password),
|
||||
'expires_at' => optional($event->photobooth_expires_at)->toIso8601String(),
|
||||
'rate_limit_per_minute' => (int) $settings->rate_limit_per_minute,
|
||||
'ftp' => [
|
||||
'host' => config('photobooth.ftp.host'),
|
||||
'port' => $settings->ftp_port,
|
||||
'require_ftps' => (bool) $settings->require_ftps,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{event: Event, settings: PhotoboothSetting}
|
||||
*/
|
||||
protected function resolvePayload(): array
|
||||
{
|
||||
$resource = $this->resource;
|
||||
|
||||
if ($resource instanceof Event) {
|
||||
return [
|
||||
'event' => $resource,
|
||||
'settings' => PhotoboothSetting::current(),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'event' => $resource['event'] ?? $resource,
|
||||
'settings' => $resource['settings'] ?? PhotoboothSetting::current(),
|
||||
];
|
||||
}
|
||||
|
||||
protected function buildFtpUrl(Event $event, PhotoboothSetting $settings, ?string $password): ?string
|
||||
{
|
||||
$host = config('photobooth.ftp.host');
|
||||
$username = $event->photobooth_username;
|
||||
|
||||
if (! $host || ! $username || ! $password) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$scheme = $settings->require_ftps ? 'ftps' : 'ftp';
|
||||
$port = $settings->ftp_port ?: config('photobooth.ftp.port', 21);
|
||||
|
||||
return sprintf('%s://%s:%s@%s:%d', $scheme, $username, $password, $host, $port);
|
||||
}
|
||||
}
|
||||
24
app/Models/CoolifyActionLog.php
Normal file
24
app/Models/CoolifyActionLog.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class CoolifyActionLog extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'payload' => 'array',
|
||||
'response' => 'array',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,13 @@
|
||||
namespace App\Models;
|
||||
|
||||
use App\Services\EventJoinTokenService;
|
||||
use Illuminate\Contracts\Encryption\DecryptException;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
|
||||
class Event extends Model
|
||||
{
|
||||
@@ -22,6 +24,13 @@ class Event extends Model
|
||||
'is_active' => 'boolean',
|
||||
'name' => 'array',
|
||||
'description' => 'array',
|
||||
'photobooth_enabled' => 'boolean',
|
||||
'photobooth_expires_at' => 'datetime',
|
||||
'photobooth_metadata' => 'array',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'photobooth_password_encrypted',
|
||||
];
|
||||
|
||||
protected static function booted(): void
|
||||
@@ -159,4 +168,26 @@ class Event extends Model
|
||||
|
||||
$this->attributes['settings'] = json_encode($value ?? []);
|
||||
}
|
||||
|
||||
public function getPhotoboothPasswordAttribute(): ?string
|
||||
{
|
||||
$encrypted = $this->attributes['photobooth_password_encrypted'] ?? null;
|
||||
|
||||
if (! $encrypted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return Crypt::decryptString($encrypted);
|
||||
} catch (DecryptException) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public function setPhotoboothPasswordAttribute(?string $value): void
|
||||
{
|
||||
$this->attributes['photobooth_password_encrypted'] = $value
|
||||
? Crypt::encryptString($value)
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,17 +6,29 @@ 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 Illuminate\Support\Facades\Schema;
|
||||
use Znck\Eloquent\Relations\BelongsToThrough as BelongsToThroughRelation;
|
||||
use Znck\Eloquent\Traits\BelongsToThrough;
|
||||
|
||||
class Photo extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use BelongsToThrough;
|
||||
use HasFactory;
|
||||
|
||||
public const SOURCE_GUEST_PWA = 'guest_pwa';
|
||||
|
||||
public const SOURCE_TENANT_ADMIN = 'tenant_admin';
|
||||
|
||||
public const SOURCE_PHOTOBOOTH = 'photobooth';
|
||||
|
||||
public const SOURCE_UNKNOWN = 'unknown';
|
||||
|
||||
protected static ?array $columnCache = null;
|
||||
|
||||
protected $table = 'photos';
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'is_featured' => 'boolean',
|
||||
'metadata' => 'array',
|
||||
@@ -26,6 +38,7 @@ class Photo extends Model
|
||||
|
||||
protected $attributes = [
|
||||
'security_scan_status' => 'pending',
|
||||
'ingest_source' => self::SOURCE_GUEST_PWA,
|
||||
];
|
||||
|
||||
public function mediaAsset(): BelongsTo
|
||||
@@ -63,6 +76,20 @@ class Photo extends Model
|
||||
return $this->hasMany(PhotoLike::class);
|
||||
}
|
||||
|
||||
public static function supportsFilenameColumn(): bool
|
||||
{
|
||||
return static::hasColumn('filename');
|
||||
}
|
||||
|
||||
public static function hasColumn(string $column): bool
|
||||
{
|
||||
if (static::$columnCache === null) {
|
||||
static::$columnCache = Schema::getColumnListing((new self)->getTable());
|
||||
}
|
||||
|
||||
return in_array($column, static::$columnCache, true);
|
||||
}
|
||||
|
||||
public function tenant(): BelongsToThroughRelation
|
||||
{
|
||||
return $this->belongsToThrough(
|
||||
|
||||
49
app/Models/PhotoboothSetting.php
Normal file
49
app/Models/PhotoboothSetting.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class PhotoboothSetting extends Model
|
||||
{
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'require_ftps' => 'boolean',
|
||||
'allowed_ip_ranges' => 'array',
|
||||
];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::saved(fn () => static::flushCache());
|
||||
static::deleted(fn () => static::flushCache());
|
||||
}
|
||||
|
||||
public static function current(): self
|
||||
{
|
||||
return Cache::remember('photobooth.settings', now()->addMinutes(10), function () {
|
||||
$defaults = [
|
||||
'ftp_port' => config('photobooth.ftp.port', 2121),
|
||||
'rate_limit_per_minute' => config('photobooth.rate_limit_per_minute', 20),
|
||||
'expiry_grace_days' => config('photobooth.expiry_grace_days', 1),
|
||||
'require_ftps' => false,
|
||||
'allowed_ip_ranges' => null,
|
||||
'control_service_base_url' => config('photobooth.control_service.base_url'),
|
||||
'control_service_token_identifier' => null,
|
||||
];
|
||||
|
||||
return static::query()->firstOrCreate(['id' => 1], $defaults);
|
||||
});
|
||||
}
|
||||
|
||||
public static function flushCache(): void
|
||||
{
|
||||
Cache::forget('photobooth.settings');
|
||||
}
|
||||
|
||||
public function ftpHost(): ?string
|
||||
{
|
||||
return config('photobooth.ftp.host');
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,9 @@ namespace App\Providers\Filament;
|
||||
|
||||
use App\Filament\Blog\Resources\CategoryResource;
|
||||
use App\Filament\Blog\Resources\PostResource;
|
||||
use App\Filament\Resources\CoolifyActionLogs\CoolifyActionLogResource;
|
||||
use App\Filament\Resources\LegalPageResource;
|
||||
use App\Filament\Widgets\CoolifyPlatformHealth;
|
||||
use App\Filament\Widgets\CreditAlertsWidget;
|
||||
use App\Filament\Widgets\PlatformStatsWidget;
|
||||
use App\Filament\Widgets\RevenueTrendWidget;
|
||||
@@ -59,6 +61,7 @@ class SuperAdminPanelProvider extends PanelProvider
|
||||
TopTenantsByRevenue::class,
|
||||
TopTenantsByUploads::class,
|
||||
\App\Filament\Widgets\StorageCapacityWidget::class,
|
||||
CoolifyPlatformHealth::class,
|
||||
])
|
||||
->middleware([
|
||||
EncryptCookies::class,
|
||||
@@ -84,6 +87,7 @@ class SuperAdminPanelProvider extends PanelProvider
|
||||
PostResource::class,
|
||||
CategoryResource::class,
|
||||
LegalPageResource::class,
|
||||
CoolifyActionLogResource::class,
|
||||
])
|
||||
->authGuard('web');
|
||||
|
||||
|
||||
138
app/Services/Coolify/CoolifyClient.php
Normal file
138
app/Services/Coolify/CoolifyClient.php
Normal file
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Coolify;
|
||||
|
||||
use Illuminate\Http\Client\Factory as HttpFactory;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class CoolifyClient
|
||||
{
|
||||
public function __construct(private readonly HttpFactory $http) {}
|
||||
|
||||
public function serviceStatus(string $serviceId): array
|
||||
{
|
||||
return $this->cached("coolify.service.$serviceId", fn () => $this->get("/services/{$serviceId}"), 30);
|
||||
}
|
||||
|
||||
public function recentDeployments(string $serviceId, int $limit = 5): array
|
||||
{
|
||||
return $this->cached("coolify.deployments.$serviceId", function () use ($serviceId, $limit) {
|
||||
$response = $this->get("/services/{$serviceId}/deployments?per_page={$limit}");
|
||||
|
||||
return Arr::get($response, 'data', []);
|
||||
}, 60);
|
||||
}
|
||||
|
||||
public function restartService(string $serviceId, ?Authenticatable $actor = null): array
|
||||
{
|
||||
return $this->dispatchAction($serviceId, 'restart', function () use ($serviceId) {
|
||||
return $this->post("/services/{$serviceId}/actions/restart");
|
||||
}, $actor);
|
||||
}
|
||||
|
||||
public function redeployService(string $serviceId, ?Authenticatable $actor = null): array
|
||||
{
|
||||
return $this->dispatchAction($serviceId, 'redeploy', function () use ($serviceId) {
|
||||
return $this->post("/services/{$serviceId}/actions/redeploy");
|
||||
}, $actor);
|
||||
}
|
||||
|
||||
protected function cached(string $key, callable $callback, int $seconds): mixed
|
||||
{
|
||||
return Cache::remember($key, now()->addSeconds($seconds), $callback);
|
||||
}
|
||||
|
||||
protected function get(string $path): array
|
||||
{
|
||||
$response = $this->request()->get($path);
|
||||
|
||||
if ($response->failed()) {
|
||||
$this->logFailure('GET', $path, $response);
|
||||
throw new RequestException($response);
|
||||
}
|
||||
|
||||
return $response->json() ?? [];
|
||||
}
|
||||
|
||||
protected function post(string $path, array $payload = []): array
|
||||
{
|
||||
$response = $this->request()->post($path, $payload);
|
||||
|
||||
if ($response->failed()) {
|
||||
$this->logFailure('POST', $path, $response);
|
||||
throw new RequestException($response);
|
||||
}
|
||||
|
||||
return $response->json() ?? [];
|
||||
}
|
||||
|
||||
protected function request(): PendingRequest
|
||||
{
|
||||
$baseUrl = config('coolify.api.base_url');
|
||||
$token = config('coolify.api.token');
|
||||
$timeout = config('coolify.api.timeout', 5);
|
||||
|
||||
if (! $baseUrl || ! $token) {
|
||||
throw new \RuntimeException('Coolify API is not configured.');
|
||||
}
|
||||
|
||||
return $this->http
|
||||
->baseUrl($baseUrl)
|
||||
->timeout($timeout)
|
||||
->acceptJson()
|
||||
->withToken($token);
|
||||
}
|
||||
|
||||
protected function logFailure(string $method, string $path, \Illuminate\Http\Client\Response $response): void
|
||||
{
|
||||
Log::error('[Coolify] API request failed', [
|
||||
'method' => $method,
|
||||
'path' => $path,
|
||||
'status' => $response->status(),
|
||||
'body' => $response->body(),
|
||||
]);
|
||||
}
|
||||
|
||||
protected function dispatchAction(string $serviceId, string $action, callable $callback, ?Authenticatable $actor = null): array
|
||||
{
|
||||
$payload = [];
|
||||
|
||||
try {
|
||||
$response = $callback();
|
||||
} catch (\Throwable $exception) {
|
||||
$this->logAction($serviceId, $action, $payload, [
|
||||
'error' => $exception->getMessage(),
|
||||
], null, $actor);
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
$this->logAction($serviceId, $action, $payload, $response, $response['status'] ?? null, $actor);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
protected function logAction(
|
||||
string $serviceId,
|
||||
string $action,
|
||||
array $payload,
|
||||
array $response,
|
||||
?int $status,
|
||||
?Authenticatable $actor = null,
|
||||
): void {
|
||||
CoolifyActionLog::create([
|
||||
'user_id' => $actor?->getAuthIdentifier() ?? auth()->id(),
|
||||
'service_id' => $serviceId,
|
||||
'action' => $action,
|
||||
'payload' => $payload,
|
||||
'response' => $response,
|
||||
'status_code' => $status,
|
||||
]);
|
||||
}
|
||||
}
|
||||
use App\Models\CoolifyActionLog;
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
129
app/Services/Help/HelpSyncService.php
Normal file
129
app/Services/Help/HelpSyncService.php
Normal file
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Help;
|
||||
|
||||
use Illuminate\Filesystem\Filesystem;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use League\CommonMark\Environment\Environment;
|
||||
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
|
||||
use League\CommonMark\MarkdownConverter;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\Finder\SplFileInfo;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
class HelpSyncService
|
||||
{
|
||||
private MarkdownConverter $converter;
|
||||
|
||||
public function __construct(private readonly Filesystem $files)
|
||||
{
|
||||
$environment = new Environment;
|
||||
$environment->addExtension(new CommonMarkCoreExtension);
|
||||
$this->converter = new MarkdownConverter($environment);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<string, int>>
|
||||
*/
|
||||
public function sync(): array
|
||||
{
|
||||
$sourcePath = base_path(config('help.source_path'));
|
||||
|
||||
if (! $this->files->exists($sourcePath)) {
|
||||
throw new RuntimeException('Help source directory not found: '.$sourcePath);
|
||||
}
|
||||
|
||||
$articles = collect();
|
||||
|
||||
foreach (config('help.audiences', []) as $audience) {
|
||||
$audiencePath = $sourcePath.DIRECTORY_SEPARATOR.$audience;
|
||||
|
||||
if (! $this->files->isDirectory($audiencePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$files = $this->files->allFiles($audiencePath);
|
||||
|
||||
/** @var SplFileInfo $file */
|
||||
foreach ($files as $file) {
|
||||
if ($file->getExtension() !== 'md') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parsed = $this->parseFile($file);
|
||||
$articles->push($parsed);
|
||||
}
|
||||
}
|
||||
|
||||
$disk = config('help.disk');
|
||||
$compiledPath = trim(config('help.compiled_path'), '/');
|
||||
$written = [];
|
||||
|
||||
foreach ($articles->groupBy(fn ($article) => $article['audience'].'::'.$article['locale']) as $key => $group) {
|
||||
[$audience, $locale] = explode('::', $key);
|
||||
$path = sprintf('%s/%s/%s/articles.json', $compiledPath, $audience, $locale);
|
||||
Storage::disk($disk)->put($path, $group->values()->toJson(JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||
Cache::forget($this->cacheKey($audience, $locale));
|
||||
$written[$audience][$locale] = $group->count();
|
||||
}
|
||||
|
||||
return $written;
|
||||
}
|
||||
|
||||
private function parseFile(SplFileInfo $file): array
|
||||
{
|
||||
$contents = $this->files->get($file->getPathname());
|
||||
|
||||
if (! Str::startsWith($contents, "---\n")) {
|
||||
throw new RuntimeException('Missing front matter in '.$file->getPathname());
|
||||
}
|
||||
|
||||
$pattern = '/^---\s*\n(?P<yaml>.*?)-{3}\s*\n(?P<body>.*)$/s';
|
||||
|
||||
if (! preg_match($pattern, $contents, $matches)) {
|
||||
throw new RuntimeException('Unable to parse front matter for '.$file->getPathname());
|
||||
}
|
||||
|
||||
$frontMatter = Yaml::parse(trim($matches['yaml'] ?? '')) ?? [];
|
||||
$frontMatter = array_map(static fn ($value) => $value ?? null, $frontMatter);
|
||||
|
||||
$this->validateFrontMatter($frontMatter, $file->getPathname());
|
||||
|
||||
$body = trim($matches['body'] ?? '');
|
||||
$html = trim($this->converter->convert($body)->getContent());
|
||||
$updatedAt = now()->setTimestamp($file->getMTime())->toISOString();
|
||||
|
||||
return array_merge($frontMatter, [
|
||||
'body_markdown' => $body,
|
||||
'body_html' => $html,
|
||||
'source_path' => $file->getRelativePathname(),
|
||||
'updated_at' => $updatedAt,
|
||||
]);
|
||||
}
|
||||
|
||||
private function validateFrontMatter(array $frontMatter, string $path): void
|
||||
{
|
||||
$required = config('help.required_front_matter', []);
|
||||
|
||||
foreach ($required as $key) {
|
||||
if (! Arr::exists($frontMatter, $key)) {
|
||||
throw new RuntimeException(sprintf('Missing front matter key "%s" in %s', $key, $path));
|
||||
}
|
||||
}
|
||||
|
||||
$audiences = config('help.audiences', []);
|
||||
$audience = $frontMatter['audience'];
|
||||
|
||||
if (! in_array($audience, $audiences, true)) {
|
||||
throw new RuntimeException(sprintf('Invalid audience "%s" in %s', $audience, $path));
|
||||
}
|
||||
}
|
||||
|
||||
private function cacheKey(string $audience, string $locale): string
|
||||
{
|
||||
return sprintf('help.%s.%s', $audience, $locale);
|
||||
}
|
||||
}
|
||||
75
app/Services/Photobooth/ControlServiceClient.php
Normal file
75
app/Services/Photobooth/ControlServiceClient.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Photobooth;
|
||||
|
||||
use App\Models\PhotoboothSetting;
|
||||
use Illuminate\Http\Client\Factory as HttpFactory;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use RuntimeException;
|
||||
|
||||
class ControlServiceClient
|
||||
{
|
||||
public function __construct(private readonly HttpFactory $httpFactory) {}
|
||||
|
||||
public function provisionUser(array $payload, ?PhotoboothSetting $settings = null): array
|
||||
{
|
||||
return $this->send('post', '/users', $payload, $settings);
|
||||
}
|
||||
|
||||
public function rotateUser(string $username, array $payload = [], ?PhotoboothSetting $settings = null): array
|
||||
{
|
||||
return $this->send('post', "/users/{$username}/rotate", $payload, $settings);
|
||||
}
|
||||
|
||||
public function deleteUser(string $username, ?PhotoboothSetting $settings = null): array
|
||||
{
|
||||
return $this->send('delete', "/users/{$username}", [], $settings);
|
||||
}
|
||||
|
||||
public function syncConfig(array $payload, ?PhotoboothSetting $settings = null): array
|
||||
{
|
||||
return $this->send('post', '/config', $payload, $settings);
|
||||
}
|
||||
|
||||
protected function send(string $method, string $path, array $payload = [], ?PhotoboothSetting $settings = null): array
|
||||
{
|
||||
$response = $this->request($settings)->{$method}($path, $payload);
|
||||
|
||||
if ($response->failed()) {
|
||||
$message = sprintf('Photobooth control request failed for %s', $path);
|
||||
Log::error($message, [
|
||||
'path' => $path,
|
||||
'payload' => Arr::except($payload, ['password']),
|
||||
'status' => $response->status(),
|
||||
'body' => $response->json() ?? $response->body(),
|
||||
]);
|
||||
|
||||
throw new RequestException($response);
|
||||
}
|
||||
|
||||
return $response->json() ?? [];
|
||||
}
|
||||
|
||||
protected function request(?PhotoboothSetting $settings = null): PendingRequest
|
||||
{
|
||||
$settings ??= PhotoboothSetting::current();
|
||||
|
||||
$baseUrl = $settings->control_service_base_url ?? config('photobooth.control_service.base_url');
|
||||
$token = config('photobooth.control_service.token');
|
||||
|
||||
if (! $baseUrl || ! $token) {
|
||||
throw new RuntimeException('Photobooth control service is not configured.');
|
||||
}
|
||||
|
||||
$timeout = config('photobooth.control_service.timeout', 5);
|
||||
|
||||
return $this->httpFactory
|
||||
->baseUrl($baseUrl)
|
||||
->timeout($timeout)
|
||||
->withToken($token)
|
||||
->acceptJson();
|
||||
}
|
||||
}
|
||||
46
app/Services/Photobooth/CredentialGenerator.php
Normal file
46
app/Services/Photobooth/CredentialGenerator.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Photobooth;
|
||||
|
||||
use App\Models\Event;
|
||||
|
||||
class CredentialGenerator
|
||||
{
|
||||
public function __construct(
|
||||
private readonly int $usernameLength = 8,
|
||||
private readonly int $passwordLength = 8,
|
||||
private readonly string $usernamePrefix = 'pb'
|
||||
) {}
|
||||
|
||||
public function generateUsername(Event $event): string
|
||||
{
|
||||
$maxLength = min(10, max(6, $this->usernameLength));
|
||||
$prefix = substr($this->usernamePrefix, 0, $maxLength - 3);
|
||||
$tenantMarker = strtoupper(substr($event->tenant?->slug ?? $event->tenant?->name ?? 'x', 0, 1));
|
||||
|
||||
$remaining = $maxLength - strlen($prefix) - 1;
|
||||
$randomSegment = $this->randomSegment(max(3, $remaining));
|
||||
|
||||
return strtoupper($prefix.$tenantMarker.substr($randomSegment, 0, $remaining));
|
||||
}
|
||||
|
||||
public function generatePassword(): string
|
||||
{
|
||||
$length = min(8, max(6, $this->passwordLength));
|
||||
|
||||
return $this->randomSegment($length);
|
||||
}
|
||||
|
||||
protected function randomSegment(int $length): string
|
||||
{
|
||||
$alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
$poolSize = strlen($alphabet);
|
||||
$value = '';
|
||||
|
||||
for ($i = 0; $i < $length; $i++) {
|
||||
$value .= $alphabet[random_int(0, $poolSize - 1)];
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
263
app/Services/Photobooth/PhotoboothIngestService.php
Normal file
263
app/Services/Photobooth/PhotoboothIngestService.php
Normal file
@@ -0,0 +1,263 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Photobooth;
|
||||
|
||||
use App\Jobs\ProcessPhotoSecurityScan;
|
||||
use App\Models\Emotion;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventPackage;
|
||||
use App\Models\Photo;
|
||||
use App\Services\Packages\PackageLimitEvaluator;
|
||||
use App\Services\Packages\PackageUsageTracker;
|
||||
use App\Services\Storage\EventStorageManager;
|
||||
use App\Support\ImageHelper;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class PhotoboothIngestService
|
||||
{
|
||||
private bool $hasFilenameColumn;
|
||||
|
||||
private bool $hasPathColumn;
|
||||
|
||||
public function __construct(
|
||||
private readonly EventStorageManager $storageManager,
|
||||
private readonly PackageLimitEvaluator $packageLimitEvaluator,
|
||||
private readonly PackageUsageTracker $packageUsageTracker,
|
||||
) {
|
||||
$this->hasFilenameColumn = Photo::supportsFilenameColumn();
|
||||
$this->hasPathColumn = Schema::hasColumn('photos', 'path');
|
||||
}
|
||||
|
||||
public function ingest(Event $event, ?int $maxFiles = null): array
|
||||
{
|
||||
$tenant = $event->tenant;
|
||||
|
||||
if (! $tenant) {
|
||||
return ['processed' => 0, 'skipped' => 0];
|
||||
}
|
||||
|
||||
$importDisk = config('photobooth.import.disk', 'photobooth');
|
||||
$basePath = ltrim((string) $event->photobooth_path, '/');
|
||||
if (str_starts_with($basePath, 'photobooth/')) {
|
||||
$basePath = substr($basePath, strlen('photobooth/'));
|
||||
}
|
||||
|
||||
$disk = Storage::disk($importDisk);
|
||||
|
||||
if ($basePath === '' || ! $disk->directoryExists($basePath)) {
|
||||
return ['processed' => 0, 'skipped' => 0];
|
||||
}
|
||||
|
||||
$allowedExtensions = config('photobooth.import.allowed_extensions', ['jpg', 'jpeg', 'png', 'webp']);
|
||||
$limit = $maxFiles ?? (int) config('photobooth.import.max_files_per_run', 50);
|
||||
|
||||
$files = collect($disk->files($basePath))
|
||||
->filter(fn ($file) => $this->isAllowedFile($file, $allowedExtensions))
|
||||
->sort()
|
||||
->take($limit);
|
||||
|
||||
if ($files->isEmpty()) {
|
||||
return ['processed' => 0, 'skipped' => 0];
|
||||
}
|
||||
|
||||
$event->loadMissing(['tenant', 'eventPackage.package', 'eventPackages.package', 'storageAssignments.storageTarget']);
|
||||
$tenant->refresh();
|
||||
|
||||
$violation = $this->packageLimitEvaluator->assessPhotoUpload($tenant, $event->id, $event);
|
||||
if ($violation !== null) {
|
||||
Log::warning('[Photobooth] Upload blocked due to package violation', [
|
||||
'event_id' => $event->id,
|
||||
'code' => $violation['code'],
|
||||
]);
|
||||
|
||||
return ['processed' => 0, 'skipped' => $files->count()];
|
||||
}
|
||||
|
||||
$eventPackage = $this->packageLimitEvaluator->resolveEventPackageForPhotoUpload($tenant, $event->id, $event);
|
||||
|
||||
$disk = $this->storageManager->getHotDiskForEvent($event);
|
||||
$processed = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($files as $file) {
|
||||
if ($this->reachedPhotoLimit($eventPackage)) {
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->importFile($event, $eventPackage, $disk, $importDisk, $file);
|
||||
if ($result) {
|
||||
$processed++;
|
||||
Storage::disk($importDisk)->delete($file);
|
||||
} else {
|
||||
$skipped++;
|
||||
}
|
||||
} catch (\Throwable $exception) {
|
||||
$skipped++;
|
||||
Log::error('[Photobooth] Failed to ingest file', [
|
||||
'event_id' => $event->id,
|
||||
'file' => $file,
|
||||
'message' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return ['processed' => $processed, 'skipped' => $skipped];
|
||||
}
|
||||
|
||||
protected function importFile(
|
||||
Event $event,
|
||||
?EventPackage $eventPackage,
|
||||
string $destinationDisk,
|
||||
string $importDisk,
|
||||
string $file,
|
||||
): bool {
|
||||
$stream = Storage::disk($importDisk)->readStream($file);
|
||||
|
||||
if (! $stream) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$extension = strtolower(pathinfo($file, PATHINFO_EXTENSION) ?: 'jpg');
|
||||
$filename = Str::uuid().'.'.$extension;
|
||||
$eventSlug = $event->slug ?? 'event-'.$event->id;
|
||||
$destinationPath = "events/{$eventSlug}/photos/{$filename}";
|
||||
|
||||
try {
|
||||
Storage::disk($destinationDisk)->put($destinationPath, $stream);
|
||||
} finally {
|
||||
if (is_resource($stream)) {
|
||||
fclose($stream);
|
||||
}
|
||||
}
|
||||
|
||||
$thumbnailPath = "events/{$eventSlug}/thumbnails/{$filename}";
|
||||
$thumbnailRelative = ImageHelper::makeThumbnailOnDisk($destinationDisk, $destinationPath, $thumbnailPath, 640, 82);
|
||||
$thumbnailToStore = $thumbnailRelative ?? $destinationPath;
|
||||
|
||||
$size = Storage::disk($destinationDisk)->size($destinationPath);
|
||||
$mimeType = Storage::disk($destinationDisk)->mimeType($destinationPath) ?? 'image/jpeg';
|
||||
$originalName = basename($file);
|
||||
|
||||
$photo = null;
|
||||
|
||||
DB::transaction(function () use (
|
||||
&$photo,
|
||||
$event,
|
||||
$eventPackage,
|
||||
$destinationDisk,
|
||||
$destinationPath,
|
||||
$thumbnailRelative,
|
||||
$thumbnailToStore,
|
||||
$mimeType,
|
||||
$size,
|
||||
$filename,
|
||||
$originalName,
|
||||
) {
|
||||
$payload = [
|
||||
'event_id' => $event->id,
|
||||
'emotion_id' => $this->resolveEmotionId($event),
|
||||
'original_name' => $originalName,
|
||||
'mime_type' => $mimeType,
|
||||
'size' => $size,
|
||||
'file_path' => $destinationPath,
|
||||
'thumbnail_path' => $thumbnailToStore,
|
||||
'status' => 'pending',
|
||||
'guest_name' => Photo::SOURCE_PHOTOBOOTH,
|
||||
'ingest_source' => Photo::SOURCE_PHOTOBOOTH,
|
||||
'ip_address' => null,
|
||||
];
|
||||
|
||||
if ($this->hasFilenameColumn) {
|
||||
$payload['filename'] = $filename;
|
||||
}
|
||||
if ($this->hasPathColumn) {
|
||||
$payload['path'] = $destinationPath;
|
||||
}
|
||||
|
||||
$photo = Photo::create($payload);
|
||||
|
||||
$asset = $this->storageManager->recordAsset($event, $destinationDisk, $destinationPath, [
|
||||
'variant' => 'original',
|
||||
'mime_type' => $mimeType,
|
||||
'size_bytes' => $size,
|
||||
'checksum' => null,
|
||||
'status' => 'hot',
|
||||
'processed_at' => now(),
|
||||
'photo_id' => $photo->id,
|
||||
]);
|
||||
|
||||
if ($thumbnailRelative) {
|
||||
$this->storageManager->recordAsset($event, $destinationDisk, $thumbnailRelative, [
|
||||
'variant' => 'thumbnail',
|
||||
'mime_type' => 'image/jpeg',
|
||||
'status' => 'hot',
|
||||
'processed_at' => now(),
|
||||
'photo_id' => $photo->id,
|
||||
]);
|
||||
}
|
||||
|
||||
$photo->update(['media_asset_id' => $asset->id]);
|
||||
|
||||
$dimensions = @getimagesize(Storage::disk($destinationDisk)->path($destinationPath));
|
||||
|
||||
if ($dimensions !== false) {
|
||||
$photo->update([
|
||||
'width' => Arr::get($dimensions, 0),
|
||||
'height' => Arr::get($dimensions, 1),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($eventPackage) {
|
||||
$previousUsed = $eventPackage->used_photos;
|
||||
$eventPackage->increment('used_photos');
|
||||
$eventPackage->refresh();
|
||||
$this->packageUsageTracker->recordPhotoUsage($eventPackage, $previousUsed, 1);
|
||||
}
|
||||
});
|
||||
|
||||
ProcessPhotoSecurityScan::dispatch($photo->id);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function isAllowedFile(string $file, array $extensions): bool
|
||||
{
|
||||
$extension = strtolower(pathinfo($file, PATHINFO_EXTENSION) ?: '');
|
||||
|
||||
return $extension !== '' && in_array($extension, $extensions, true);
|
||||
}
|
||||
|
||||
protected function reachedPhotoLimit(?EventPackage $eventPackage): bool
|
||||
{
|
||||
if (! $eventPackage || ! $eventPackage->package) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$limit = $eventPackage->package->max_photos;
|
||||
|
||||
return $limit !== null
|
||||
&& $limit > 0
|
||||
&& $eventPackage->used_photos >= $limit;
|
||||
}
|
||||
|
||||
protected function resolveEmotionId(Event $event): ?int
|
||||
{
|
||||
if (! Photo::hasColumn('emotion_id')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$existing = $event->photos()->value('emotion_id');
|
||||
|
||||
if ($existing) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
return Emotion::query()->value('id');
|
||||
}
|
||||
}
|
||||
163
app/Services/Photobooth/PhotoboothProvisioner.php
Normal file
163
app/Services/Photobooth/PhotoboothProvisioner.php
Normal file
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Photobooth;
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\PhotoboothSetting;
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class PhotoboothProvisioner
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ControlServiceClient $client,
|
||||
private readonly CredentialGenerator $credentialGenerator
|
||||
) {}
|
||||
|
||||
public function enable(Event $event, ?PhotoboothSetting $settings = null): Event
|
||||
{
|
||||
$settings ??= PhotoboothSetting::current();
|
||||
$event->loadMissing('tenant');
|
||||
|
||||
return DB::transaction(function () use ($event, $settings) {
|
||||
$username = $this->generateUniqueUsername($event, $settings);
|
||||
$password = $this->credentialGenerator->generatePassword();
|
||||
$path = $this->buildPath($event);
|
||||
$expiresAt = $this->resolveExpiry($event, $settings);
|
||||
|
||||
$payload = [
|
||||
'username' => $username,
|
||||
'password' => $password,
|
||||
'path' => $path,
|
||||
'rate_limit_per_minute' => $settings->rate_limit_per_minute,
|
||||
'expires_at' => $expiresAt?->toIso8601String(),
|
||||
'ftp_port' => $settings->ftp_port,
|
||||
];
|
||||
|
||||
$this->client->provisionUser($payload, $settings);
|
||||
|
||||
$event->forceFill([
|
||||
'photobooth_enabled' => true,
|
||||
'photobooth_username' => $username,
|
||||
'photobooth_password' => $password,
|
||||
'photobooth_path' => $path,
|
||||
'photobooth_expires_at' => $expiresAt,
|
||||
'photobooth_status' => 'active',
|
||||
'photobooth_last_provisioned_at' => now(),
|
||||
'photobooth_metadata' => [
|
||||
'rate_limit_per_minute' => $settings->rate_limit_per_minute,
|
||||
],
|
||||
])->save();
|
||||
|
||||
return tap($event->refresh(), function (Event $refreshed) use ($password) {
|
||||
$refreshed->setAttribute('plain_photobooth_password', $password);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public function rotate(Event $event, ?PhotoboothSetting $settings = null): Event
|
||||
{
|
||||
$settings ??= PhotoboothSetting::current();
|
||||
|
||||
if (! $event->photobooth_enabled || ! $event->photobooth_username) {
|
||||
return $this->enable($event, $settings);
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($event, $settings) {
|
||||
$password = $this->credentialGenerator->generatePassword();
|
||||
$expiresAt = $this->resolveExpiry($event, $settings);
|
||||
|
||||
$payload = [
|
||||
'password' => $password,
|
||||
'expires_at' => $expiresAt?->toIso8601String(),
|
||||
'rate_limit_per_minute' => $settings->rate_limit_per_minute,
|
||||
];
|
||||
|
||||
$this->client->rotateUser($event->photobooth_username, $payload, $settings);
|
||||
|
||||
$event->forceFill([
|
||||
'photobooth_password' => $password,
|
||||
'photobooth_expires_at' => $expiresAt,
|
||||
'photobooth_status' => 'active',
|
||||
'photobooth_last_provisioned_at' => now(),
|
||||
])->save();
|
||||
|
||||
return tap($event->refresh(), function (Event $refreshed) use ($password) {
|
||||
$refreshed->setAttribute('plain_photobooth_password', $password);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public function disable(Event $event, ?PhotoboothSetting $settings = null): Event
|
||||
{
|
||||
if (! $event->photobooth_username) {
|
||||
return $event;
|
||||
}
|
||||
|
||||
$settings ??= PhotoboothSetting::current();
|
||||
|
||||
return DB::transaction(function () use ($event, $settings) {
|
||||
try {
|
||||
$this->client->deleteUser($event->photobooth_username, $settings);
|
||||
} catch (\Throwable $exception) {
|
||||
Log::warning('Photobooth account deletion failed', [
|
||||
'event_id' => $event->id,
|
||||
'username' => $event->photobooth_username,
|
||||
'message' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
$event->forceFill([
|
||||
'photobooth_enabled' => false,
|
||||
'photobooth_status' => 'inactive',
|
||||
'photobooth_username' => null,
|
||||
'photobooth_password' => null,
|
||||
'photobooth_path' => null,
|
||||
'photobooth_expires_at' => null,
|
||||
'photobooth_last_deprovisioned_at' => now(),
|
||||
])->save();
|
||||
|
||||
return $event->refresh();
|
||||
});
|
||||
}
|
||||
|
||||
protected function resolveExpiry(Event $event, PhotoboothSetting $settings): CarbonInterface
|
||||
{
|
||||
$eventEnd = $event->date ? Carbon::parse($event->date) : now();
|
||||
$graceDays = max(0, (int) $settings->expiry_grace_days);
|
||||
|
||||
return $eventEnd->copy()
|
||||
->endOfDay()
|
||||
->addDays($graceDays);
|
||||
}
|
||||
|
||||
protected function generateUniqueUsername(Event $event, PhotoboothSetting $settings): string
|
||||
{
|
||||
$maxAttempts = 10;
|
||||
|
||||
for ($i = 0; $i < $maxAttempts; $i++) {
|
||||
$username = $this->credentialGenerator->generateUsername($event);
|
||||
|
||||
$exists = Event::query()
|
||||
->where('photobooth_username', $username)
|
||||
->whereKeyNot($event->getKey())
|
||||
->exists();
|
||||
|
||||
if (! $exists) {
|
||||
return strtolower($username);
|
||||
}
|
||||
}
|
||||
|
||||
return strtolower('pb'.Str::random(5));
|
||||
}
|
||||
|
||||
protected function buildPath(Event $event): string
|
||||
{
|
||||
$tenantKey = $event->tenant?->slug ?? $event->tenant_id;
|
||||
|
||||
return trim((string) $tenantKey, '/').'/'.$event->getKey();
|
||||
}
|
||||
}
|
||||
54
app/Support/Help/HelpRepository.php
Normal file
54
app/Support/Help/HelpRepository.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Help;
|
||||
|
||||
use Illuminate\Contracts\Cache\Repository as CacheRepository;
|
||||
use Illuminate\Contracts\Filesystem\FileNotFoundException;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use RuntimeException;
|
||||
|
||||
class HelpRepository
|
||||
{
|
||||
public function __construct(private readonly CacheRepository $cache) {}
|
||||
|
||||
public function list(string $audience, string $locale): Collection
|
||||
{
|
||||
$key = $this->cacheKey($audience, $locale);
|
||||
|
||||
return $this->cache->remember($key, now()->addMinutes(10), fn () => $this->load($audience, $locale));
|
||||
}
|
||||
|
||||
public function find(string $audience, string $locale, string $slug): ?array
|
||||
{
|
||||
return $this->list($audience, $locale)
|
||||
->first(fn ($article) => Arr::get($article, 'slug') === $slug);
|
||||
}
|
||||
|
||||
private function load(string $audience, string $locale): Collection
|
||||
{
|
||||
$disk = config('help.disk');
|
||||
$compiledPath = trim(config('help.compiled_path'), '/');
|
||||
$path = sprintf('%s/%s/%s/articles.json', $compiledPath, $audience, $locale);
|
||||
|
||||
if (! Storage::disk($disk)->exists($path)) {
|
||||
throw new RuntimeException(sprintf('Help cache missing for %s/%s. Run help:sync.', $audience, $locale));
|
||||
}
|
||||
|
||||
try {
|
||||
$contents = Storage::disk($disk)->get($path);
|
||||
} catch (FileNotFoundException $e) {
|
||||
throw new RuntimeException($e->getMessage());
|
||||
}
|
||||
|
||||
$decoded = json_decode($contents, true, flags: JSON_THROW_ON_ERROR);
|
||||
|
||||
return collect($decoded);
|
||||
}
|
||||
|
||||
private function cacheKey(string $audience, string $locale): string
|
||||
{
|
||||
return sprintf('help.%s.%s', $audience, $locale);
|
||||
}
|
||||
}
|
||||
@@ -22,9 +22,16 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
->withCommands([
|
||||
\App\Console\Commands\CheckEventPackages::class,
|
||||
\App\Console\Commands\ExportCouponRedemptions::class,
|
||||
\App\Console\Commands\DeactivateExpiredPhotoboothAccounts::class,
|
||||
\App\Console\Commands\PhotoboothIngestCommand::class,
|
||||
\App\Console\Commands\MonitorStorageCommand::class,
|
||||
\App\Console\Commands\DispatchStorageArchiveCommand::class,
|
||||
\App\Console\Commands\CheckUploadQueuesCommand::class,
|
||||
])
|
||||
->withSchedule(function (\Illuminate\Console\Scheduling\Schedule $schedule) {
|
||||
$schedule->command('package:check-status')->dailyAt('06:00');
|
||||
$schedule->command('photobooth:cleanup-expired')->hourly()->withoutOverlapping();
|
||||
$schedule->command('photobooth:ingest')->everyFiveMinutes()->withoutOverlapping();
|
||||
})
|
||||
->withMiddleware(function (Middleware $middleware) {
|
||||
$middleware->alias([
|
||||
|
||||
11
config/coolify.php
Normal file
11
config/coolify.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'api' => [
|
||||
'base_url' => env('COOLIFY_API_BASE_URL'),
|
||||
'token' => env('COOLIFY_API_TOKEN'),
|
||||
'timeout' => (int) env('COOLIFY_API_TIMEOUT', 5),
|
||||
],
|
||||
'web_url' => env('COOLIFY_WEB_URL'),
|
||||
'services' => json_decode(env('COOLIFY_SERVICE_IDS', '{}'), true) ?? [],
|
||||
];
|
||||
@@ -47,6 +47,13 @@ return [
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
'photobooth' => [
|
||||
'driver' => 'local',
|
||||
'root' => env('PHOTOBOOTH_IMPORT_ROOT', storage_path('app/photobooth')),
|
||||
'throw' => false,
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
's3' => [
|
||||
'driver' => 's3',
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
|
||||
37
config/help.php
Normal file
37
config/help.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'source_path' => 'docs/help',
|
||||
'disk' => 'local',
|
||||
'compiled_path' => 'help',
|
||||
'audiences' => ['guest', 'admin'],
|
||||
'default_locale' => 'en',
|
||||
'fallback_locale' => 'en',
|
||||
'required_front_matter' => [
|
||||
'title',
|
||||
'locale',
|
||||
'slug',
|
||||
'audience',
|
||||
'summary',
|
||||
'version_introduced',
|
||||
'status',
|
||||
'translation_state',
|
||||
'last_reviewed_at',
|
||||
'owner',
|
||||
],
|
||||
'list_fields' => [
|
||||
'slug',
|
||||
'title',
|
||||
'summary',
|
||||
'version_introduced',
|
||||
'requires_app_version',
|
||||
'status',
|
||||
'translation_state',
|
||||
'last_reviewed_at',
|
||||
'owner',
|
||||
'related',
|
||||
'audience',
|
||||
'locale',
|
||||
'updated_at',
|
||||
],
|
||||
];
|
||||
@@ -73,6 +73,14 @@ return [
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'storage-jobs' => [
|
||||
'driver' => 'daily',
|
||||
'path' => storage_path('logs/storage-jobs.log'),
|
||||
'level' => env('LOG_LEVEL', 'info'),
|
||||
'days' => env('LOG_DAILY_DAYS', 14),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'slack' => [
|
||||
'driver' => 'slack',
|
||||
'url' => env('LOG_SLACK_WEBHOOK_URL'),
|
||||
|
||||
28
config/photobooth.php
Normal file
28
config/photobooth.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'control_service' => [
|
||||
'base_url' => env('PHOTOBOOTH_CONTROL_BASE_URL'),
|
||||
'token' => env('PHOTOBOOTH_CONTROL_TOKEN'),
|
||||
'timeout' => (int) env('PHOTOBOOTH_CONTROL_TIMEOUT', 5),
|
||||
],
|
||||
'ftp' => [
|
||||
'host' => env('PHOTOBOOTH_FTP_HOST'),
|
||||
'port' => (int) env('PHOTOBOOTH_FTP_PORT', 2121),
|
||||
],
|
||||
'credentials' => [
|
||||
'username_prefix' => env('PHOTOBOOTH_USERNAME_PREFIX', 'pb'),
|
||||
'username_length' => (int) env('PHOTOBOOTH_USERNAME_LENGTH', 8),
|
||||
'password_length' => (int) env('PHOTOBOOTH_PASSWORD_LENGTH', 8),
|
||||
],
|
||||
'rate_limit_per_minute' => (int) env('PHOTOBOOTH_RATE_LIMIT_PER_MINUTE', 20),
|
||||
'expiry_grace_days' => (int) env('PHOTOBOOTH_EXPIRY_GRACE_DAYS', 1),
|
||||
'import' => [
|
||||
'disk' => env('PHOTOBOOTH_IMPORT_DISK', 'photobooth'),
|
||||
'max_files_per_run' => (int) env('PHOTOBOOTH_IMPORT_MAX_FILES', 50),
|
||||
'allowed_extensions' => array_values(array_filter(array_map(
|
||||
fn ($ext) => strtolower(trim($ext)),
|
||||
explode(',', env('PHOTOBOOTH_ALLOWED_EXTENSIONS', 'jpg,jpeg,png,webp'))
|
||||
))),
|
||||
],
|
||||
];
|
||||
@@ -6,5 +6,42 @@ return [
|
||||
],
|
||||
|
||||
'queue_failure_alerts' => env('STORAGE_QUEUE_FAILURE_ALERTS', true),
|
||||
];
|
||||
|
||||
'capacity_thresholds' => [
|
||||
'warning' => (int) env('STORAGE_CAPACITY_WARNING', 75),
|
||||
'critical' => (int) env('STORAGE_CAPACITY_CRITICAL', 90),
|
||||
],
|
||||
|
||||
'monitor' => [
|
||||
'lock_seconds' => (int) env('STORAGE_MONITOR_LOCK_SECONDS', 300),
|
||||
'cache_minutes' => (int) env('STORAGE_MONITOR_CACHE_MINUTES', 15),
|
||||
],
|
||||
|
||||
'archive' => [
|
||||
'grace_days' => (int) env('STORAGE_ARCHIVE_GRACE_DAYS', 3),
|
||||
'lock_seconds' => (int) env('STORAGE_ARCHIVE_LOCK_SECONDS', 1800),
|
||||
'event_lock_seconds' => (int) env('STORAGE_ARCHIVE_EVENT_LOCK_SECONDS', 3600),
|
||||
'chunk' => (int) env('STORAGE_ARCHIVE_CHUNK', 25),
|
||||
'max_dispatch' => (int) env('STORAGE_ARCHIVE_MAX_DISPATCH', 100),
|
||||
],
|
||||
|
||||
'queue_health' => [
|
||||
'lock_seconds' => (int) env('STORAGE_QUEUE_HEALTH_LOCK_SECONDS', 120),
|
||||
'cache_minutes' => (int) env('STORAGE_QUEUE_HEALTH_CACHE_MINUTES', 10),
|
||||
'stalled_minutes' => (int) env('STORAGE_QUEUE_STALLED_MINUTES', 10),
|
||||
'thresholds' => [
|
||||
'default' => [
|
||||
'warning' => (int) env('STORAGE_QUEUE_DEFAULT_WARNING', 100),
|
||||
'critical' => (int) env('STORAGE_QUEUE_DEFAULT_CRITICAL', 300),
|
||||
],
|
||||
'media-storage' => [
|
||||
'warning' => (int) env('STORAGE_QUEUE_MEDIA_STORAGE_WARNING', 200),
|
||||
'critical' => (int) env('STORAGE_QUEUE_MEDIA_STORAGE_CRITICAL', 500),
|
||||
],
|
||||
'media-security' => [
|
||||
'warning' => (int) env('STORAGE_QUEUE_MEDIA_SECURITY_WARNING', 50),
|
||||
'critical' => (int) env('STORAGE_QUEUE_MEDIA_SECURITY_CRITICAL', 150),
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Archive dispatcher cron skeleton
|
||||
# Archive dispatcher cron job
|
||||
# Run nightly to move completed events to cold storage
|
||||
|
||||
set -euo pipefail
|
||||
@@ -8,6 +8,25 @@ 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
|
||||
LOG_DIR="$APP_DIR/storage/logs"
|
||||
mkdir -p "$LOG_DIR"
|
||||
LOG_FILE="$LOG_DIR/cron-archive-dispatcher.log"
|
||||
LOCK_FILE="$LOG_DIR/archive_dispatcher.lock"
|
||||
|
||||
exec 200>"$LOCK_FILE"
|
||||
if ! flock -n 200; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
timestamp() {
|
||||
date --iso-8601=seconds
|
||||
}
|
||||
|
||||
echo "[$(timestamp)] Starting storage:archive-pending" >> "$LOG_FILE"
|
||||
if /usr/bin/env php artisan storage:archive-pending --no-interaction --quiet >> "$LOG_FILE" 2>&1; then
|
||||
echo "[$(timestamp)] storage:archive-pending completed" >> "$LOG_FILE"
|
||||
else
|
||||
status=$?
|
||||
echo "[$(timestamp)] storage:archive-pending failed (exit $status)" >> "$LOG_FILE"
|
||||
exit $status
|
||||
fi
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Storage monitor cron skeleton
|
||||
# Storage monitor cron job
|
||||
# Usage: configure cron to run every 5 minutes
|
||||
|
||||
set -euo pipefail
|
||||
@@ -8,7 +8,25 @@ 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
|
||||
LOG_DIR="$APP_DIR/storage/logs"
|
||||
mkdir -p "$LOG_DIR"
|
||||
LOG_FILE="$LOG_DIR/cron-storage-monitor.log"
|
||||
LOCK_FILE="$LOG_DIR/storage_monitor.lock"
|
||||
|
||||
exec 201>"$LOCK_FILE"
|
||||
if ! flock -n 201; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
timestamp() {
|
||||
date --iso-8601=seconds
|
||||
}
|
||||
|
||||
echo "[$(timestamp)] Starting storage:monitor" >> "$LOG_FILE"
|
||||
if /usr/bin/env php artisan storage:monitor --no-interaction --quiet >> "$LOG_FILE" 2>&1; then
|
||||
echo "[$(timestamp)] storage:monitor completed" >> "$LOG_FILE"
|
||||
else
|
||||
status=$?
|
||||
echo "[$(timestamp)] storage:monitor failed (exit $status)" >> "$LOG_FILE"
|
||||
exit $status
|
||||
fi
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Upload queue health cron skeleton
|
||||
# Upload queue health cron job
|
||||
# Schedule every 10 minutes to detect stalled uploads
|
||||
|
||||
set -euo pipefail
|
||||
@@ -8,6 +8,25 @@ 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
|
||||
LOG_DIR="$APP_DIR/storage/logs"
|
||||
mkdir -p "$LOG_DIR"
|
||||
LOG_FILE="$LOG_DIR/cron-upload-queue-health.log"
|
||||
LOCK_FILE="$LOG_DIR/upload_queue_health.lock"
|
||||
|
||||
exec 202>"$LOCK_FILE"
|
||||
if ! flock -n 202; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
timestamp() {
|
||||
date --iso-8601=seconds
|
||||
}
|
||||
|
||||
echo "[$(timestamp)] Starting storage:check-upload-queues" >> "$LOG_FILE"
|
||||
if /usr/bin/env php artisan storage:check-upload-queues --no-interaction --quiet >> "$LOG_FILE" 2>&1; then
|
||||
echo "[$(timestamp)] storage:check-upload-queues completed" >> "$LOG_FILE"
|
||||
else
|
||||
status=$?
|
||||
echo "[$(timestamp)] storage:check-upload-queues failed (exit $status)" >> "$LOG_FILE"
|
||||
exit $status
|
||||
fi
|
||||
|
||||
@@ -24,6 +24,7 @@ class PhotoFactory extends Factory
|
||||
'likes_count' => $this->faker->numberBetween(0, 25),
|
||||
'is_featured' => false,
|
||||
'metadata' => ['factory' => true],
|
||||
'ingest_source' => \App\Models\Photo::SOURCE_GUEST_PWA,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -45,4 +46,3 @@ class PhotoFactory extends Factory
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?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('photobooth_settings', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedSmallInteger('ftp_port')->default(2121);
|
||||
$table->unsignedInteger('rate_limit_per_minute')->default(20);
|
||||
$table->unsignedTinyInteger('expiry_grace_days')->default(1);
|
||||
$table->boolean('require_ftps')->default(false);
|
||||
$table->json('allowed_ip_ranges')->nullable();
|
||||
$table->string('control_service_base_url')->nullable();
|
||||
$table->string('control_service_token_identifier')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('photobooth_settings');
|
||||
}
|
||||
};
|
||||
@@ -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::table('events', function (Blueprint $table) {
|
||||
$table->boolean('photobooth_enabled')->default(false)->after('settings');
|
||||
$table->string('photobooth_username', 32)->nullable()->after('photobooth_enabled');
|
||||
$table->text('photobooth_password_encrypted')->nullable()->after('photobooth_username');
|
||||
$table->string('photobooth_path')->nullable()->after('photobooth_password_encrypted');
|
||||
$table->timestamp('photobooth_expires_at')->nullable()->after('photobooth_path');
|
||||
$table->string('photobooth_status', 32)->default('inactive')->after('photobooth_expires_at');
|
||||
$table->timestamp('photobooth_last_provisioned_at')->nullable()->after('photobooth_status');
|
||||
$table->timestamp('photobooth_last_deprovisioned_at')->nullable()->after('photobooth_last_provisioned_at');
|
||||
$table->json('photobooth_metadata')->nullable()->after('photobooth_last_deprovisioned_at');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('events', function (Blueprint $table) {
|
||||
$table->dropColumn([
|
||||
'photobooth_enabled',
|
||||
'photobooth_username',
|
||||
'photobooth_password_encrypted',
|
||||
'photobooth_path',
|
||||
'photobooth_expires_at',
|
||||
'photobooth_status',
|
||||
'photobooth_last_provisioned_at',
|
||||
'photobooth_last_deprovisioned_at',
|
||||
'photobooth_metadata',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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::table('photos', function (Blueprint $table) {
|
||||
if (! Schema::hasColumn('photos', 'ingest_source')) {
|
||||
$table->string('ingest_source', 32)
|
||||
->default('guest_pwa')
|
||||
->after('guest_name');
|
||||
$table->index('ingest_source');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('photos', function (Blueprint $table) {
|
||||
if (Schema::hasColumn('photos', 'ingest_source')) {
|
||||
$table->dropIndex('photos_ingest_source_index');
|
||||
$table->dropColumn('ingest_source');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
<?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::table('photos', function (Blueprint $table) {
|
||||
if (! Schema::hasColumn('photos', 'filename')) {
|
||||
$table->string('filename')->nullable()->after('guest_name');
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('photos', 'original_name')) {
|
||||
$table->string('original_name')->nullable()->after('filename');
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('photos', 'mime_type')) {
|
||||
$table->string('mime_type', 191)->nullable()->after('original_name');
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('photos', 'size')) {
|
||||
$table->unsignedBigInteger('size')->nullable()->after('mime_type');
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('photos', 'width')) {
|
||||
$table->unsignedInteger('width')->nullable()->after('thumbnail_path');
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('photos', 'height')) {
|
||||
$table->unsignedInteger('height')->nullable()->after('width');
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('photos', 'status')) {
|
||||
$table->string('status', 32)->default('pending')->after('height');
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('photos', 'uploader_id')) {
|
||||
$table->foreignId('uploader_id')
|
||||
->nullable()
|
||||
->after('status')
|
||||
->constrained('users')
|
||||
->nullOnDelete();
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('photos', 'ip_address')) {
|
||||
$table->string('ip_address', 45)->nullable()->after('uploader_id');
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('photos', 'user_agent')) {
|
||||
$table->text('user_agent')->nullable()->after('ip_address');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('photos', function (Blueprint $table) {
|
||||
foreach (['user_agent', 'ip_address'] as $column) {
|
||||
if (Schema::hasColumn('photos', $column)) {
|
||||
$table->dropColumn($column);
|
||||
}
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('photos', 'uploader_id')) {
|
||||
$table->dropConstrainedForeignId('uploader_id');
|
||||
}
|
||||
|
||||
foreach (['status', 'height', 'width', 'size', 'mime_type', 'original_name', 'filename'] as $column) {
|
||||
if (Schema::hasColumn('photos', $column)) {
|
||||
$table->dropColumn($column);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
<?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('coolify_action_logs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('service_id', 64);
|
||||
$table->string('action', 64);
|
||||
$table->json('payload')->nullable();
|
||||
$table->json('response')->nullable();
|
||||
$table->unsignedSmallInteger('status_code')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('coolify_action_logs');
|
||||
}
|
||||
};
|
||||
93
docs/deployment/coolify.md
Normal file
93
docs/deployment/coolify.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# Coolify Deployment Guide
|
||||
|
||||
Coolify provides a managed Docker host with service orchestration, logs, metrics, CI hooks, and secret management. This document outlines how to run Fotospiel (including the Photobooth FTP stack) on Coolify and how to prepare for SuperAdmin observability.
|
||||
|
||||
## 1. Services to deploy
|
||||
|
||||
| Service | Notes |
|
||||
|---------|-------|
|
||||
| **Laravel App** | Build from this repo. Expose port 8080. Attach environment variables from `.env`. |
|
||||
| **Scheduler** | Clone the app container; command `php artisan schedule:work`. |
|
||||
| **Queue workers** | Use `docs/queue-supervisor/queue-worker.sh` scripts (default, media-storage, media-security). |
|
||||
| **Horizon (optional)** | Add service executing `docs/queue-supervisor/horizon.sh`. |
|
||||
| **Redis / Database** | Use Coolify managed services or bring your own (RDS/Aurora). |
|
||||
| **vsftpd container** | Host FTP on port 2121 and mount the shared Photobooth volume. |
|
||||
| **Photobooth Control Service** | Lightweight API (Go/Node/Laravel Octane) that Coolify can redeploy alongside vsftpd. |
|
||||
|
||||
### Volumes
|
||||
|
||||
- `storage-app` (Laravel `storage`, uploads, compiled views).
|
||||
- `photobooth` (shared between vsftpd, control-service, and Laravel).
|
||||
- Database/Redis volumes if self-hosted.
|
||||
|
||||
Mount these volumes in Coolify under “Persistent Storage” for each service.
|
||||
|
||||
## 2. Environment & Secrets
|
||||
|
||||
Configure the following keys inside Coolify’s “Environment Variables” panel:
|
||||
|
||||
- All standard Laravel vars (`APP_KEY`, `DB_*`, `QUEUE_CONNECTION`, `AWS_*` etc.).
|
||||
- Photobooth block (as documented in `.env.example`): `PHOTOBOOTH_CONTROL_*`, `PHOTOBOOTH_FTP_HOST/PORT`, `PHOTOBOOTH_IMPORT_*`.
|
||||
- New Coolify integration vars (planned for SuperAdmin widgets):
|
||||
|
||||
```
|
||||
COOLIFY_API_BASE_URL=https://coolify.example.com/api/v1
|
||||
COOLIFY_API_TOKEN=... # generated per project
|
||||
COOLIFY_SERVICE_IDS={"app":"svc_xxx","ftp":"svc_yyy"}
|
||||
```
|
||||
|
||||
Store the JSON mapping so Laravel knows which Coolify “service” controls the app, queue, vsftpd, etc.
|
||||
|
||||
## 3. Deploy steps
|
||||
|
||||
1. Add the Git repository to Coolify (build hook). Configure the Dockerfile build args if needed.
|
||||
2. Define services:
|
||||
- **App**: HTTP worker (build & run). Health check `/up`.
|
||||
- **Scheduler**: same image, command `php artisan schedule:work`.
|
||||
- **Queue**: command `/var/www/html/docs/queue-supervisor/queue-worker.sh default`.
|
||||
- Additional queue types as separate services.
|
||||
3. Configure networks so all services share the same internal bridge, allowing Redis/DB connectivity.
|
||||
4. Attach the `photobooth` volume to both vsftpd and the Laravel app.
|
||||
5. Run `php artisan migrate --force` from Coolify’s “One-off command” console after the first deploy.
|
||||
6. Seed storage targets if necessary (`php artisan db:seed --class=MediaStorageTargetSeeder --force`).
|
||||
|
||||
## 4. Metrics & Controls for SuperAdmin
|
||||
|
||||
To surface Coolify data inside the platform:
|
||||
|
||||
1. **API Token** – create a Coolify PAT with read access to services and optional “actions” scope.
|
||||
2. **Laravel config** – introduce `config/coolify.php` with base URL, token, and service IDs.
|
||||
3. **Service client** – wrap Coolify endpoints:
|
||||
- `GET /services/{id}` → CPU/RAM, status, last deploy, git SHA.
|
||||
- `POST /services/{id}/actions/restart` for restart buttons.
|
||||
- `GET /deployments/{id}/logs` for tailing last deploy logs.
|
||||
4. **Filament widgets** – in SuperAdmin dashboard add:
|
||||
- **Platform Health**: per service status (App, Queue, Scheduler, vsftpd, Control Service).
|
||||
- **Recent Deploys**: table of the last Coolify deployments and commit messages.
|
||||
- **Actions**: buttons (with confirmations) to restart vsftpd or re-run `photobooth:ingest` service.
|
||||
|
||||
Ensure all requests are audited (database table) and require SuperAdmin role.
|
||||
|
||||
## 5. FTP container controls
|
||||
|
||||
Coolify makes it easier to:
|
||||
|
||||
- View vsftpd metrics (CPU, memory, network) directly; replicate those values in SuperAdmin via the API.
|
||||
- Trigger redeploys of the vsftpd service when Photobooth settings change (Laravel can call Coolify’s redeploy endpoint).
|
||||
- Inspect container logs from SuperAdmin by proxying `GET /services/{id}/logs?tail=200`.
|
||||
|
||||
## 6. Monitoring & Alerts
|
||||
|
||||
- Configure Coolify Webhooks (Deploy succeeded/failed, service unhealthy) → point to a Laravel route, mark incidents in `photobooth_metadata`.
|
||||
- Use Coolify’s built-in notifications (Slack, email) for infrastructure-level alerts; complement with Laravel notifications for application-level events (e.g., ingest failures).
|
||||
|
||||
## 7. Production readiness checklist
|
||||
|
||||
1. All services built and running in Coolify with health checks.
|
||||
2. Volumes backed up (database snapshots + `storage` tarball).
|
||||
3. Photobooth shared volume mounted & writeable by vsftpd + Laravel.
|
||||
4. Environment variables set (APP_KEY, DB creds, Photobooth block, Coolify API token).
|
||||
5. Scheduler & queue services logging to Coolify.
|
||||
6. SuperAdmin Filament widgets wired to Coolify API (optional but recommended).
|
||||
|
||||
With this setup you can manage deployments, restarts, and metrics centrally while still using Laravel’s built-in scheduler and worker scripts. The next step is implementing the `CoolifyClient` + Filament widgets described above.
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
This guide describes the recommended, repeatable way to run the Fotospiel platform in Docker for production or high-fidelity staging environments. It pairs a multi-stage build (PHP-FPM + asset pipeline) with a Compose stack that includes Nginx, worker processes, Redis, and MySQL.
|
||||
|
||||
> **Coolify users:** see `docs/deployment/coolify.md` for service definitions, secrets, and how to wire the same containers (web, queue, scheduler, vsftpd) inside Coolify. That document builds on the base Docker instructions below.
|
||||
|
||||
## 1. Prerequisites
|
||||
|
||||
- Docker Engine 24+ and Docker Compose v2.
|
||||
@@ -106,4 +108,3 @@ Because the app image keeps the authoritative copy of the code, each container r
|
||||
- Hook into your observability stack (e.g., ship container logs to Loki or ELK).
|
||||
|
||||
With the provided configuration you can bootstrap a consistent Docker-based deployment across environments while keeping queue workers, migrations, and asset builds manageable. Adjust service definitions as needed for staging vs. production.
|
||||
|
||||
|
||||
81
docs/help/README.md
Normal file
81
docs/help/README.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# Help System Blueprint
|
||||
|
||||
This folder defines the bilingual help center that serves both guest app users and customer admins. It stores the source Markdown, translation metadata, and the publishing workflow that feeds the in-app help experiences.
|
||||
|
||||
## Goals
|
||||
- Give each audience (Guests vs. Admins) tailored, task-based documentation in German and English.
|
||||
- Keep a single Git-tracked source of truth with translation status and review history.
|
||||
- Deliver lightweight, searchable payloads to both apps (offline capable for guests, richer navigation for admins).
|
||||
- Allow non-developers to contribute through Filament while still publishing Markdown to the repo.
|
||||
|
||||
## Directory Layout
|
||||
```
|
||||
docs/help/
|
||||
├── README.md # This blueprint
|
||||
├── templates/ # Authoring templates per locale
|
||||
├── guest/ # Guest-focused articles (paired locales)
|
||||
│ ├── index.en.md
|
||||
│ └── index.de.md
|
||||
└── admin/ # Customer admin articles (paired locales)
|
||||
├── index.en.md
|
||||
└── index.de.md
|
||||
```
|
||||
- Articles live in the audience folder and follow the naming pattern `<slug>.<locale>.md` (e.g., `offline-sync.en.md`).
|
||||
- Each article includes YAML front matter to describe metadata used by the app and Filament resource.
|
||||
|
||||
## Front Matter Contract
|
||||
```yaml
|
||||
title: "Getting Started"
|
||||
locale: en
|
||||
slug: getting-started
|
||||
audience: guest # guest | admin | shared
|
||||
summary: "Install the guest app and join an event in under a minute."
|
||||
version_introduced: 2025.4
|
||||
requires_app_version: "^3.2.0" # optional semver filter for the apps
|
||||
status: published # draft | review | published
|
||||
translation_state: aligned # draft | needs_update | aligned
|
||||
last_reviewed_at: 2025-01-10
|
||||
owner: cx-team@fotospiel.app # rotational owner or team list
|
||||
related:
|
||||
- slug: offline-sync
|
||||
- slug: privacy-basics
|
||||
```
|
||||
- `translation_state` tracks whether the paired locale matches the canonical source. CI should warn when one locale lags.
|
||||
- `requires_app_version` lets an app hide docs when the installed build lacks that feature.
|
||||
|
||||
## Authoring & Translation Workflow
|
||||
1. **Plan** – open an issue referencing the article slug and target locales.
|
||||
2. **Draft** – copy the locale template, write the content, keep paragraphs short for mobile.
|
||||
3. **Review** – tech review (feature owner) + linguistic review (native speaker). Update `status` and `last_reviewed_at`.
|
||||
4. **Translate** – for the paired locale, mark `translation_state` accordingly and link PRs together.
|
||||
5. **Publish** – merge to `main`. A CI job should rebuild the Markdown cache and push structured JSON to storage (e.g., S3 or Redis) consumed by Laravel.
|
||||
|
||||
> Tip: prefer screenshots only in admin docs; guest docs focus on concise steps + illustrations already shipped in the app.
|
||||
|
||||
## Delivery Architecture
|
||||
- **Source of truth**: Markdown in this folder.
|
||||
- **Processing**: an Artisan job (`help:sync`) parses Markdown via `league/commonmark`, validates front matter, generates per-locale JSON bundles, and stores them under `storage/app/help/<audience>/<locale>.json`.
|
||||
- **API**:
|
||||
- `GET /api/help?audience=guest&locale=de` → paginated list with `title`, `summary`, `slug`, `version_introduced`, `updated_at`.
|
||||
- `GET /api/help/{slug}` → full article body + related slugs.
|
||||
- Guests access anonymously; admins require auth middleware (so we can show restricted docs).
|
||||
- **Caching**:
|
||||
- Cache list + article JSON for 10 minutes in Redis; bust cache whenever the sync job runs.
|
||||
- Guest app prefetches JSON during first online session and stores it in IndexedDB for offline reading.
|
||||
- Admin app keeps only recent items client-side but relies on online search for canonical versions.
|
||||
- **Search**:
|
||||
- During sync, build Lunr/MiniSearch indexes per audience/locale and ship them to the app bundles for offline search.
|
||||
|
||||
## Contextual Access
|
||||
- **Guest App**: add `?` entry points (floating button, settings, upload errors) that deep-link to slugs. Offer article-level language toggles.
|
||||
- **Admin App / Filament**: show contextual “Need help?” links near complex forms, routing to `/help/{slug}` in an in-app modal. Provide a global Help center route with sidebar nav and server-driven breadcrumbs.
|
||||
- **Standalone Web**: optional `/help` public site generated from the same Markdown using Vite/React and server-side rendering for SEO.
|
||||
|
||||
## Governance & Backlog
|
||||
- Track work in `docs/todo/help.md` (create if needed) and link issues to article slugs.
|
||||
- Add CI checks:
|
||||
- Ensure every `.en.md` file has a matching `.de.md` file with equal `slug`/`version_introduced`.
|
||||
- Validate required front matter keys.
|
||||
- Future enhancements:
|
||||
- Integrate with Paddle customer portal for billing-related admin help.
|
||||
- Add analytics event (non-PII) for article views through the app to measure usefulness.
|
||||
37
docs/help/admin/admin-issue-resolution.de.md
Normal file
37
docs/help/admin/admin-issue-resolution.de.md
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
title: "Troubleshooting & Incident-Playbooks"
|
||||
locale: de
|
||||
slug: admin-issue-resolution
|
||||
audience: admin
|
||||
summary: "Leitfäden für typische Admin-Vorfälle – von hängenden Uploads bis zu Billing-Sperren."
|
||||
version_introduced: 2025.4
|
||||
requires_app_version: "^3.2.0"
|
||||
status: draft
|
||||
translation_state: aligned
|
||||
last_reviewed_at: 2025-02-22
|
||||
owner: reliability@fotospiel.app
|
||||
related:
|
||||
- slug: live-ops-control
|
||||
- slug: privacy-and-support
|
||||
---
|
||||
|
||||
## Upload-Vorfälle
|
||||
| Symptom | Diagnose | Lösung |
|
||||
| --- | --- | --- |
|
||||
| Warteschlange >10 Min fest | Live-Ops-Health-Widget prüfen | `php artisan media:backfill-thumbnails --tenant=XYZ` ausführen, Event neu öffnen |
|
||||
| Einzelner Gast blockiert | Geräte-Limit erreicht | Limit unter Event → Upload-Regeln erhöhen oder Gast bittet Entwürfe zu löschen |
|
||||
| Fotos ohne EXIF | Gast importiert Screenshots | Kein Fehler; Hinweis geben, dass EXIF optional ist |
|
||||
|
||||
## Zugriffsprobleme
|
||||
- **Admin kommt nicht rein**: Prüfen, ob Einladung akzeptiert wurde; über *Team → Einladung erneut senden* resetten. Bei SSO Pflicht Zuordnung kontrollieren.
|
||||
- **Gast kann nicht beitreten**: Event-Status muss *Published* sein; direkten Join-Link `https://app.fotospiel.com/join/<code>` teilen.
|
||||
|
||||
## Billing & Quoten
|
||||
- Paddle-Webhook-Fehler sperrt Uploads: `storage/logs/paddle.log` prüfen, Webhook im Paddle-Dashboard erneut senden, anschließend Abo-Status toggeln.
|
||||
- Speicher zu 90 % voll: Archivierung vorziehen oder Add-on im Paddle-Kundenportal buchen.
|
||||
|
||||
## Kommunikationsvorlagen
|
||||
Nutze die vorformulierten Antworten in `docs/content/fotospiel_howto_artikel_detailliert.md`, um Messaging konsistent zu halten.
|
||||
|
||||
### Weitere Hilfe
|
||||
Eskalation an reliability@fotospiel.app mit Event-ID, Kunde und Zeitstempel. Screenshots/Logs anhängen, wenn verfügbar.
|
||||
37
docs/help/admin/admin-issue-resolution.en.md
Normal file
37
docs/help/admin/admin-issue-resolution.en.md
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
title: "Troubleshooting & Issue Resolution"
|
||||
locale: en
|
||||
slug: admin-issue-resolution
|
||||
audience: admin
|
||||
summary: "Playbooks for the most common admin-side incidents, from stuck uploads to billing locks."
|
||||
version_introduced: 2025.4
|
||||
requires_app_version: "^3.2.0"
|
||||
status: draft
|
||||
translation_state: aligned
|
||||
last_reviewed_at: 2025-02-22
|
||||
owner: reliability@fotospiel.app
|
||||
related:
|
||||
- slug: live-ops-control
|
||||
- slug: privacy-and-support
|
||||
---
|
||||
|
||||
## Upload incidents
|
||||
| Symptom | Diagnosis | Fix |
|
||||
| --- | --- | --- |
|
||||
| Queue stuck >10 min | Check Live Ops health widget | Run `php artisan media:backfill-thumbnails --tenant=XYZ` then reopen event |
|
||||
| Specific guest blocked | Guest reached per-device limit | Increase limit under Event → Upload rules or ask them to clear drafts |
|
||||
| Photos missing EXIF | Guest imported screenshots | No action; remind them that EXIF is optional |
|
||||
|
||||
## Access issues
|
||||
- **Admin cannot log in**: verify invite accepted; reset via *Team → Resend invite*. Check SSO mapping if enforced.
|
||||
- **Guest cannot join**: confirm event status is *Published* and share direct join URL `https://app.fotospiel.com/join/<code>`.
|
||||
|
||||
## Billing & quotas
|
||||
- Paddle webhook failure locks uploads: check `storage/logs/paddle.log`, re-send webhook via Paddle dashboard, then toggle the subscription status.
|
||||
- Storage 90% full: run archive early or purchase add-on via Paddle customer portal.
|
||||
|
||||
## Communication templates
|
||||
Reuse the canned responses under `docs/content/fotospiel_howto_artikel_detailliert.md` to keep messaging consistent.
|
||||
|
||||
### Need more help?
|
||||
Escalate to reliability@fotospiel.app with the event ID, customer account, and timestamp. Attach screenshots/logs when possible.
|
||||
38
docs/help/admin/event-prep-checklist.de.md
Normal file
38
docs/help/admin/event-prep-checklist.de.md
Normal file
@@ -0,0 +1,38 @@
|
||||
---
|
||||
title: "Checkliste Event-Vorbereitung"
|
||||
locale: de
|
||||
slug: event-prep-checklist
|
||||
audience: admin
|
||||
summary: "48-Stunden-Countdown, damit Geräte, Gäste und Automationen ready sind, bevor es losgeht."
|
||||
version_introduced: 2025.4
|
||||
requires_app_version: "^3.2.0"
|
||||
status: draft
|
||||
translation_state: aligned
|
||||
last_reviewed_at: 2025-02-22
|
||||
owner: ops@fotospiel.app
|
||||
related:
|
||||
- slug: live-ops-control
|
||||
- slug: post-event-wrapup
|
||||
---
|
||||
|
||||
## 48–24 Stunden vorher
|
||||
- [ ] Event in der Admin-App mit korrekter Zeitzone + Aufbewahrungsfrist anlegen.
|
||||
- [ ] Titelbild (1200×630) hochladen und Übersetzungen für Titel/Beschreibung prüfen.
|
||||
- [ ] Gästelisten importieren oder QR-Badges erzeugen.
|
||||
- [ ] Push-Vorlagen testen (Reminder, Achievement-Freischaltung).
|
||||
|
||||
## 24–2 Stunden vorher
|
||||
- [ ] `tenant:attach-demo-event` im Staging ausführen, um den Ablauf mit dem Team zu proben.
|
||||
- [ ] Join-QR nahe Eingang und Fotoboxen ausdrucken oder anzeigen.
|
||||
- [ ] WLAN-SSID/Passwort-Beschilderung vorbereiten.
|
||||
- [ ] Moderationsregeln mit Kundenvertrag abgleichen (z. B. explizite Inhalte blocken, Freigabe nötig).
|
||||
- [ ] Paddle/RevenueCat-Status prüfen (alle Ampeln auf Grün).
|
||||
|
||||
## Letzte 2 Stunden
|
||||
- [ ] Demodaten aus dem Live-Event entfernen.
|
||||
- [ ] Gäste-App auf Testgeräten öffnen und den Schnellstart durchspielen.
|
||||
- [ ] Live-Ops-Ansicht auf Tablet/Laptop in Bühnennähe starten.
|
||||
- [ ] Team zu Eskalationswegen briefen (Supportkontakte, Ersatzgeräte, Foto-Guidelines).
|
||||
|
||||
### Weitere Hilfe
|
||||
Siehe `live-ops-control` für Echtzeit-Monitoring oder melde dich bei ops@fotospiel.app.
|
||||
38
docs/help/admin/event-prep-checklist.en.md
Normal file
38
docs/help/admin/event-prep-checklist.en.md
Normal file
@@ -0,0 +1,38 @@
|
||||
---
|
||||
title: "Event Preparation Checklist"
|
||||
locale: en
|
||||
slug: event-prep-checklist
|
||||
audience: admin
|
||||
summary: "A 48-hour countdown to ensure devices, guests, and automations are ready before doors open."
|
||||
version_introduced: 2025.4
|
||||
requires_app_version: "^3.2.0"
|
||||
status: draft
|
||||
translation_state: aligned
|
||||
last_reviewed_at: 2025-02-22
|
||||
owner: ops@fotospiel.app
|
||||
related:
|
||||
- slug: live-ops-control
|
||||
- slug: post-event-wrapup
|
||||
---
|
||||
|
||||
## 48–24 hours before
|
||||
- [ ] Create the event in the Admin app with correct timezone + retention policy.
|
||||
- [ ] Upload cover artwork (1200×630) and ensure translations exist for titles/descriptions.
|
||||
- [ ] Import guest lists or generate QR badges if needed.
|
||||
- [ ] Test push notification templates (reminders, achievement unlocks).
|
||||
|
||||
## 24–2 hours before
|
||||
- [ ] Run `tenant:attach-demo-event` in staging to rehearse workflow with staff.
|
||||
- [ ] Print or display the join QR near entrance and photobooth areas.
|
||||
- [ ] Prepare onsite Wi-Fi SSID/password signage.
|
||||
- [ ] Confirm that automatic moderation rules match the client contract (e.g., block explicit content, require approval).
|
||||
- [ ] Verify Paddle/RevenueCat status dashboards show green.
|
||||
|
||||
## Final 2 hours
|
||||
- [ ] Clear demo data from the live event.
|
||||
- [ ] Open the guest app on test devices and complete the getting-started flow.
|
||||
- [ ] Start the Live Ops screen on a tablet/laptop near the stage.
|
||||
- [ ] Brief staff on escalation paths (support contacts, backup devices, photo guidelines).
|
||||
|
||||
### Need more help?
|
||||
Open `live-ops-control` for real-time monitoring tips or reach out to ops@fotospiel.app.
|
||||
25
docs/help/admin/index.de.md
Normal file
25
docs/help/admin/index.de.md
Normal file
@@ -0,0 +1,25 @@
|
||||
---
|
||||
title: "Hilfecenter für Event-Admins"
|
||||
locale: de
|
||||
slug: admin-help-index
|
||||
audience: admin
|
||||
summary: "Betriebsleitfäden für Event-Verantwortliche: Onboarding, Setup, Live-Steuerung und Nachbereitung."
|
||||
version_introduced: 2025.4
|
||||
status: draft
|
||||
translation_state: aligned
|
||||
last_reviewed_at: 2025-02-22
|
||||
owner: cx-team@fotospiel.app
|
||||
related: []
|
||||
---
|
||||
|
||||
Hier findest du alle Informationen, die du als Event-Admin für einen reibungslosen Ablauf brauchst. Die Artikel sind entlang des Event-Lebenszyklus sortiert:
|
||||
|
||||
| Phase | Leitfragen | Artikel-Slug |
|
||||
| --- | --- | --- |
|
||||
| Konto & Team | Wie lade ich Mitarbeitende ein und setze Branding auf? | `tenant-dashboard-overview` |
|
||||
| Event-Vorbereitung | Welche Checkliste erledige ich vor Einlass? | `event-prep-checklist` |
|
||||
| Live-Betrieb | Wie überwache ich Uploads, moderiere Inhalte und sende Hinweise? | `live-ops-control` |
|
||||
| Abschluss & Compliance | Wie funktionieren Export, Archiv und Datenschutz? | `post-event-wrapup` |
|
||||
| Troubleshooting | Was tun bei Upload-Problemen, Geräteverlust oder Billing-Fragen? | `admin-issue-resolution` |
|
||||
|
||||
Nutze die Navigationsleiste in der Admin-App für den Schnellzugriff oder öffne `/help/admin` im Desktop-Browser für die vollständige Ansicht mit Breadcrumbs und verwandten Artikeln.
|
||||
25
docs/help/admin/index.en.md
Normal file
25
docs/help/admin/index.en.md
Normal file
@@ -0,0 +1,25 @@
|
||||
---
|
||||
title: "Customer Admin Help Center"
|
||||
locale: en
|
||||
slug: admin-help-index
|
||||
audience: admin
|
||||
summary: "Operational playbooks for event owners: onboarding, event setup, live control, and post-event delivery."
|
||||
version_introduced: 2025.4
|
||||
status: draft
|
||||
translation_state: aligned
|
||||
last_reviewed_at: 2025-02-22
|
||||
owner: cx-team@fotospiel.app
|
||||
related: []
|
||||
---
|
||||
|
||||
This portal collects everything event admins need to configure customer accounts, run events smoothly, and resolve issues quickly. Articles are grouped by lifecycle:
|
||||
|
||||
| Phase | Key Questions | Article Slug |
|
||||
| --- | --- | --- |
|
||||
| Account Setup | How do I invite staff and configure branding? | `tenant-dashboard-overview` |
|
||||
| Event Preparation | What checklists should I complete before doors open? | `event-prep-checklist` |
|
||||
| Live Operations | How do I monitor uploads, moderate content, and trigger announcements? | `live-ops-control` |
|
||||
| Wrap-up & Compliance | How are exports, archives, and privacy handled? | `post-event-wrapup` |
|
||||
| Troubleshooting | How to handle upload issues, device loss, billing, etc. | `admin-issue-resolution` |
|
||||
|
||||
Use the navigation sidebar inside the admin app for faster access, or open `/help/admin` in a desktop browser for the full layout with breadcrumbs and related links.
|
||||
39
docs/help/admin/live-ops-control.de.md
Normal file
39
docs/help/admin/live-ops-control.de.md
Normal file
@@ -0,0 +1,39 @@
|
||||
---
|
||||
title: "Live-Ops-Steuerung"
|
||||
locale: de
|
||||
slug: live-ops-control
|
||||
audience: admin
|
||||
summary: "Uploads überwachen, Inhalte moderieren und Durchsagen versenden, während das Event läuft."
|
||||
version_introduced: 2025.4
|
||||
requires_app_version: "^3.2.0"
|
||||
status: draft
|
||||
translation_state: aligned
|
||||
last_reviewed_at: 2025-02-22
|
||||
owner: ops@fotospiel.app
|
||||
related:
|
||||
- slug: event-prep-checklist
|
||||
- slug: admin-issue-resolution
|
||||
---
|
||||
|
||||
## Dashboard-Widgets
|
||||
- **Upload-Durchsatz** – Fotos/Minute, farblich markiert bei Rückständen >25.
|
||||
- **Gerätegesundheit** – Top-Geräte mit Fehlern (Berechtigung verweigert, Speicher voll).
|
||||
- **Moderationswarteschlange** – gemeldete Fotos zur Freigabe; Moderator:innen zuweisen.
|
||||
- **Ankündigungen** – Push/Banner erstellen; Sprachversionen möglich.
|
||||
|
||||
## Typischer Ablauf
|
||||
1. Live-Ops-Seite auf Tablet anheften. Auto-Refresh auf 15 Sekunden stellen.
|
||||
2. Durchsatz beobachten, sobald Gäste eintreffen; direkt nach Zeremonien sind >40/min üblich.
|
||||
3. Wächst der Rückstau, Banner senden („Bitte kurz online bleiben“ oder „Serienaufnahme reduzieren“).
|
||||
4. Gemeldete Inhalte zügig bearbeiten; Policy verlangt Aktion innerhalb von 10 Minuten.
|
||||
5. *Achievement-Trigger* nutzen, um Badges manuell zu vergeben, falls Automationen ausfallen.
|
||||
|
||||
## Eskalationsmatrix
|
||||
| Problem | Erste Aktion | Eskalation an |
|
||||
| --- | --- | --- |
|
||||
| Upload-Warteschlange fest | Health Check ausführen → Event erneut synchronisieren | Reliability Rufbereitschaft |
|
||||
| Anstößiger Inhalt | Foto ausblenden → Beweis herunterladen → Veranstalter informieren | Legal Duty Officer |
|
||||
| Billing-Lock | Paddle-Dashboard prüfen → Zahlungsstatus bestätigen | Finance |
|
||||
|
||||
### Weitere Hilfe
|
||||
Siehe `admin-issue-resolution` für detailliertes Troubleshooting oder melde dich im Slack-Channel #ops-help.
|
||||
39
docs/help/admin/live-ops-control.en.md
Normal file
39
docs/help/admin/live-ops-control.en.md
Normal file
@@ -0,0 +1,39 @@
|
||||
---
|
||||
title: "Live Ops Control"
|
||||
locale: en
|
||||
slug: live-ops-control
|
||||
audience: admin
|
||||
summary: "Monitor uploads, moderate content, and push announcements while the event is live."
|
||||
version_introduced: 2025.4
|
||||
requires_app_version: "^3.2.0"
|
||||
status: draft
|
||||
translation_state: aligned
|
||||
last_reviewed_at: 2025-02-22
|
||||
owner: ops@fotospiel.app
|
||||
related:
|
||||
- slug: event-prep-checklist
|
||||
- slug: admin-issue-resolution
|
||||
---
|
||||
|
||||
## Dashboard widgets
|
||||
- **Upload throughput** – photos/minute, highlighted when backlog >25.
|
||||
- **Device health** – top devices experiencing errors (permission denied, storage full).
|
||||
- **Moderation queue** – flagged photos awaiting approval; assign to moderators.
|
||||
- **Announcements** – compose push/banner messages; supports locale-specific text.
|
||||
|
||||
## Typical workflow
|
||||
1. Pin the Live Ops page on a tablet. Set auto-refresh to 15 seconds.
|
||||
2. Watch the throughput graph as doors open; expect spike to 40+/min right after ceremonies.
|
||||
3. If backlog grows, broadcast a banner reminding guests to stay online or reduce burst uploads.
|
||||
4. Moderate flagged items quickly; policies require action within 10 minutes.
|
||||
5. Use the *Achievement trigger* widget to award badges manually if automation criteria fail.
|
||||
|
||||
## Escalation matrix
|
||||
| Issue | First action | Escalate to |
|
||||
| --- | --- | --- |
|
||||
| Upload queue stuck | Run health check → re-sync event | Reliability on-call |
|
||||
| Offensive content | Hide photo → download evidence → notify organizer | Legal duty officer |
|
||||
| Billing lock | Check Paddle dashboard → confirm payment status | Finance |
|
||||
|
||||
### Need more help?
|
||||
Open `admin-issue-resolution` for detailed troubleshooting or ping #ops-help in Slack.
|
||||
33
docs/help/admin/post-event-wrapup.de.md
Normal file
33
docs/help/admin/post-event-wrapup.de.md
Normal file
@@ -0,0 +1,33 @@
|
||||
---
|
||||
title: "Nachbereitung & Abschluss"
|
||||
locale: de
|
||||
slug: post-event-wrapup
|
||||
audience: admin
|
||||
summary: "Highlights exportieren, Daten archivieren und Datenschutzpflichten binnen 72 Stunden erfüllen."
|
||||
version_introduced: 2025.4
|
||||
requires_app_version: "^3.2.0"
|
||||
status: draft
|
||||
translation_state: aligned
|
||||
last_reviewed_at: 2025-02-22
|
||||
owner: success@fotospiel.app
|
||||
related:
|
||||
- slug: event-prep-checklist
|
||||
- slug: privacy-and-support
|
||||
---
|
||||
|
||||
## Erste 24 Stunden
|
||||
- Dankes-Push/E-Mail mit kuratierten Highlights senden (bis zu 40 Fotos auswählen → *Link teilen*).
|
||||
- Admin-CSV exportieren (Uploads, Likes, Meldungen) für die eigene Ablage.
|
||||
- Moderationswarteschlange prüfen, damit keine Meldung offen bleibt.
|
||||
|
||||
## Innerhalb von 72 Stunden
|
||||
- Aktion *Archivieren & Bereinigen* starten (Einstellungen → Datenlebenszyklus). Kopiert Medien in Cold Storage und löscht temporäre Caches.
|
||||
- Gästen Download-Links bereitstellen, falls vertraglich zugesagt.
|
||||
- Bei eingegangenen DSGVO-Löschanfragen Abschluss bestätigen und Ticket-ID dokumentieren.
|
||||
|
||||
## Optionaler Follow-up
|
||||
- Event als Vorlage duplizieren für zukünftige Produktionen.
|
||||
- Erkenntnisse oder Verbesserungswünsche in `docs/todo/` festhalten.
|
||||
|
||||
### Weitere Hilfe
|
||||
Wende dich an success@fotospiel.app oder konsultiere die Legal-Pages-Ressource für Compliance-Formulierungen.
|
||||
33
docs/help/admin/post-event-wrapup.en.md
Normal file
33
docs/help/admin/post-event-wrapup.en.md
Normal file
@@ -0,0 +1,33 @@
|
||||
---
|
||||
title: "Post-Event Wrap-up"
|
||||
locale: en
|
||||
slug: post-event-wrapup
|
||||
audience: admin
|
||||
summary: "Export highlights, archive data, and fulfill privacy obligations within 72 hours after the event."
|
||||
version_introduced: 2025.4
|
||||
requires_app_version: "^3.2.0"
|
||||
status: draft
|
||||
translation_state: aligned
|
||||
last_reviewed_at: 2025-02-22
|
||||
owner: success@fotospiel.app
|
||||
related:
|
||||
- slug: event-prep-checklist
|
||||
- slug: privacy-and-support
|
||||
---
|
||||
|
||||
## First 24 hours
|
||||
- Send thank-you push/email with curated highlights (select up to 40 photos → *Share link*).
|
||||
- Export admin CSV (uploads, likes, reports) for your records.
|
||||
- Review moderation queue to ensure no reports remain unresolved.
|
||||
|
||||
## Within 72 hours
|
||||
- Trigger the *Archive & purge* action (Settings → Data Lifecycle). This copies media to cold storage and deletes transient caches.
|
||||
- Provide guests with download links if promised in the contract.
|
||||
- If GDPR deletion requests were filed, confirm completion and record the ticket ID.
|
||||
|
||||
## Optional follow-up
|
||||
- Duplicate the event as a template for future productions.
|
||||
- Update `docs/todo/` with learnings or improvements for the product team.
|
||||
|
||||
### Need more help?
|
||||
Reach success@fotospiel.app or consult the Legal Pages resource for compliance wording.
|
||||
34
docs/help/admin/tenant-dashboard-overview.de.md
Normal file
34
docs/help/admin/tenant-dashboard-overview.de.md
Normal file
@@ -0,0 +1,34 @@
|
||||
---
|
||||
title: "Überblick: Kunden-Dashboard"
|
||||
locale: de
|
||||
slug: tenant-dashboard-overview
|
||||
audience: admin
|
||||
summary: "Mitarbeitende einladen, Branding konfigurieren und globale Kundeneinstellungen verstehen."
|
||||
version_introduced: 2025.4
|
||||
requires_app_version: "^3.2.0"
|
||||
status: draft
|
||||
translation_state: aligned
|
||||
last_reviewed_at: 2025-02-22
|
||||
owner: onboarding@fotospiel.app
|
||||
related:
|
||||
- slug: event-prep-checklist
|
||||
- slug: admin-issue-resolution
|
||||
---
|
||||
|
||||
## Wann lesen?
|
||||
Direkt nach dem Zugriff auf einen neuen Kunden oder wenn neue Mitarbeitende eingearbeitet werden. Das Kunden-Dashboard befindet sich in der Admin-App (Filament) und bietet auf Desktop dieselben Optionen.
|
||||
|
||||
## Hauptbereiche
|
||||
1. **Home** – Überblick über laufende Events, Speicherauslastung und offene Meldungen.
|
||||
2. **Team** – Admins per E-Mail einladen, Rollen vergeben (Owner, Manager, Moderator). SSO via Azure AD/Google möglich, falls im Kundenkonto aktiviert.
|
||||
3. **Branding** – Logos hochladen, Akzentfarben wählen, lokalisierten Begrüßungstext für die Gäste-App setzen.
|
||||
4. **Rechtliches** – Impressum/Datenschutz/AGB über die Legal-Ressource pflegen; Änderungen greifen sofort.
|
||||
5. **Integrationen** – Paddle-Keys, RevenueCat-App-IDs, Webhooks und Zapier-Tokens verwalten. Keine Secrets in Dokumente kopieren.
|
||||
|
||||
## Best Practices
|
||||
- Mindestens zwei Owner-Rollen für Redundanz halten.
|
||||
- Branding oder Automationen zuerst im Staging-Kundenkonto testen.
|
||||
- Einladungen im Änderungslog (`docs/changes/`) dokumentieren.
|
||||
|
||||
### Weitere Hilfe
|
||||
Siehe `event-prep-checklist` für Event-Vorbereitung oder kontaktiere cx-team@fotospiel.app für Onboarding-Support.
|
||||
34
docs/help/admin/tenant-dashboard-overview.en.md
Normal file
34
docs/help/admin/tenant-dashboard-overview.en.md
Normal file
@@ -0,0 +1,34 @@
|
||||
---
|
||||
title: "Customer Control Center Overview"
|
||||
locale: en
|
||||
slug: tenant-dashboard-overview
|
||||
audience: admin
|
||||
summary: "Invite staff, configure branding, and understand how customer-wide settings affect every event."
|
||||
version_introduced: 2025.4
|
||||
requires_app_version: "^3.2.0"
|
||||
status: draft
|
||||
translation_state: aligned
|
||||
last_reviewed_at: 2025-02-22
|
||||
owner: onboarding@fotospiel.app
|
||||
related:
|
||||
- slug: event-prep-checklist
|
||||
- slug: admin-issue-resolution
|
||||
---
|
||||
|
||||
## When to read this
|
||||
Right after receiving access to a new customer account or when onboarding new staff. The Customer Control Center lives in the Admin app (Filament) and mirrors most options on desktop.
|
||||
|
||||
## Key areas
|
||||
1. **Home** – snapshot of live events, storage usage, unresolved reports.
|
||||
2. **Team** – invite admins via email, assign roles (Owner, Manager, Moderator). SSO via Azure AD/Google is available if enabled in customer settings.
|
||||
3. **Branding** – upload logos, choose accent colors, set localized welcome text shown in the guest app.
|
||||
4. **Legal pages** – edit Impressum/Privacy/AGB via the Legal resource; changes propagate instantly.
|
||||
5. **Integrations** – manage Paddle keys, RevenueCat app IDs, webhooks, and Zapier tokens. Never paste secrets into articles.
|
||||
|
||||
## Best practices
|
||||
- Keep at least two Owner-level accounts for redundancy.
|
||||
- Use the staging customer account to test branding or automation before touching production.
|
||||
- Document invitations in the change log (`docs/changes/`).
|
||||
|
||||
### Need more help?
|
||||
See `event-prep-checklist` for event-level prep or contact cx-team@fotospiel.app for onboarding assistance.
|
||||
36
docs/help/guest/getting-started.de.md
Normal file
36
docs/help/guest/getting-started.de.md
Normal file
@@ -0,0 +1,36 @@
|
||||
---
|
||||
title: "Schnellstart: Event betreten"
|
||||
locale: de
|
||||
slug: getting-started
|
||||
audience: guest
|
||||
summary: "Fotospiel-App installieren, Event beitreten und die Grundgesten in unter zwei Minuten lernen."
|
||||
version_introduced: 2025.4
|
||||
requires_app_version: "^3.2.0"
|
||||
status: draft
|
||||
translation_state: aligned
|
||||
last_reviewed_at: 2025-02-22
|
||||
owner: guest-success@fotospiel.app
|
||||
related:
|
||||
- slug: uploading-photos
|
||||
- slug: offline-sync
|
||||
---
|
||||
|
||||
## Wann lesen?
|
||||
Du hast einen Event-Code oder QR-Link erhalten und möchtest sofort loslegen. Voraussetzung: aktueller mobiler Browser (Safari, Chrome, Edge, Samsung Internet) und einmalige Online-Verbindung für den Erstabgleich.
|
||||
|
||||
## Schritte
|
||||
1. **Einladungslink öffnen oder QR scannen.** Der Browser zeigt die Startseite der Fotospiel-Gäste-App.
|
||||
2. **Installation für Vollbild aktivieren.** Tippe auf *Zum Home-Bildschirm* (iOS) bzw. *App installieren* (Android). Optional, aber empfohlen für Offline-Modus und schnellere Uploads.
|
||||
3. **Event-Code eingeben.** Sechs Zeichen, Groß-/Kleinschreibung egal. Nach QR-Scan wird das Feld automatisch befüllt.
|
||||
4. **Anzeigenamen wählen.** Dieser erscheint in der Event-Ansicht neben deinen Uploads. Kein Konto oder E-Mail nötig.
|
||||
5. **Kamera- & Speicherzugriff erlauben.** Wähle „Einmal erlauben“ oder „Beim Verwenden der App“, damit Fotospiel Fotos speichern kann.
|
||||
6. **Startpaket synchronisieren.** Die App lädt Alben, Achievements und Upload-Regeln herunter. Ein Fortschrittsbalken zeigt den Abschluss für den Offline-Modus.
|
||||
7. **Gesten entdecken.** Nach oben wischen öffnet die Kamera, links/rechts wechselt das Album, Langdruck auf einem Foto ermöglicht Like oder Meldung.
|
||||
|
||||
## Tipps
|
||||
- Lege die App vor dem Event in die Dock/App-Leiste, damit du sie schnell wiederfindest.
|
||||
- Teilen sich mehrere Gäste ein Gerät, setze den Anzeigenamen unter Einstellungen → Profil zwischen den Sessions zurück.
|
||||
- Screenshots verlassen dein Gerät nur, wenn du sie aktiv hochlädst.
|
||||
|
||||
### Weitere Hilfe
|
||||
Siehe `uploading-photos` für Bearbeitungs- und Batch-Uploads oder `privacy-and-support` für Fragen zum Datenschutz.
|
||||
36
docs/help/guest/getting-started.en.md
Normal file
36
docs/help/guest/getting-started.en.md
Normal file
@@ -0,0 +1,36 @@
|
||||
---
|
||||
title: "Quick Start: Join an Event"
|
||||
locale: en
|
||||
slug: getting-started
|
||||
audience: guest
|
||||
summary: "Install the Fotospiel app, join an event, and learn the core gestures in under two minutes."
|
||||
version_introduced: 2025.4
|
||||
requires_app_version: "^3.2.0"
|
||||
status: draft
|
||||
translation_state: aligned
|
||||
last_reviewed_at: 2025-02-22
|
||||
owner: guest-success@fotospiel.app
|
||||
related:
|
||||
- slug: uploading-photos
|
||||
- slug: offline-sync
|
||||
---
|
||||
|
||||
## When to read this
|
||||
You just received an event code or QR and want to start sharing photos. This guide assumes you have a modern mobile browser (Safari, Chrome, Edge, Samsung Internet) and basic connectivity once for the initial sync.
|
||||
|
||||
## Steps
|
||||
1. **Open the invite link or scan the QR.** The browser launches the Fotospiel guest app landing page.
|
||||
2. **Install for full-screen mode.** Tap *Add to Home Screen* (iOS) or *Install app* (Android). Installation is optional but unlocks offline mode and faster uploads.
|
||||
3. **Enter the event code.** Six characters, case-insensitive. If you scanned the QR, the field auto-fills.
|
||||
4. **Choose a display name.** This appears next to your uploads within the event feed. No account or email needed.
|
||||
5. **Grant camera & storage permissions.** Select “Allow once” or “Allow while using the app” so Fotospiel can capture and store photos locally.
|
||||
6. **Sync starter pack.** The app downloads current albums, achievements, and upload rules. A progress bar ensures everything is cached offline.
|
||||
7. **Explore gestures.** Swipe up to open the camera, left/right to switch album tabs, long-press a photo to like or report.
|
||||
|
||||
## Tips
|
||||
- Pin the app to your dock/home row before the event so you can reopen it instantly.
|
||||
- If several guests share one device, clear the display name in Settings → Profile between sessions.
|
||||
- Screenshots never leave your device unless you upload them manually.
|
||||
|
||||
### Need more help?
|
||||
See `uploading-photos` for editing and batch upload tips, or `privacy-and-support` if you have questions about data retention.
|
||||
29
docs/help/guest/index.de.md
Normal file
29
docs/help/guest/index.de.md
Normal file
@@ -0,0 +1,29 @@
|
||||
---
|
||||
title: "Hilfecenter für Gäste"
|
||||
locale: de
|
||||
slug: guest-help-index
|
||||
audience: guest
|
||||
summary: "Alle Infos für Teilnehmer:innen, um die Fotospiel-Gäste-App zu installieren, einem Event beizutreten und Erinnerungen zu teilen."
|
||||
version_introduced: 2025.4
|
||||
status: draft
|
||||
translation_state: aligned
|
||||
last_reviewed_at: 2025-02-22
|
||||
owner: product-support@fotospiel.app
|
||||
related: []
|
||||
---
|
||||
|
||||
Willkommen im Hilfebereich für Gäste. Starte mit dem Schnellstart, wenn du Fotospiel zum ersten Mal nutzt. Die Tabelle verlinkt auf alle Leitfäden (jeder Artikel liegt auf Deutsch und Englisch vor).
|
||||
|
||||
| Thema | Zweck | Artikel-Slug |
|
||||
| --- | --- | --- |
|
||||
| Schnellstart | App installieren, Event-Code eingeben und die wichtigsten Gesten lernen. | `getting-started` |
|
||||
| Upload-Workflow | Fotos aufnehmen, bearbeiten und mit Hintergrund-Sync hochladen. | `uploading-photos` |
|
||||
| Offline-Modus & Sync | Teilnehmen auch ohne Netz und sicherstellen, dass nichts verloren geht. | `offline-sync` |
|
||||
| Datenschutz & Support | Welche Daten gespeichert werden und wie du Hilfe kontaktierst. | `privacy-and-support` |
|
||||
|
||||
### So nutzt du die Hilfe
|
||||
- **Suche**: Verwende die Suche im Hilfebereich oder die Offline-Suche in den App-Einstellungen. Begriffe wie „Upload-Limit“ oder „Link teilen“ funktionieren.
|
||||
- **Sprache umschalten**: Klick auf das Globus-Symbol innerhalb jedes Artikels, um zwischen DE/EN zu wechseln.
|
||||
- **Kontext-Links**: Zahlreiche UI-Elemente mit `?`-Symbol führen direkt zum passenden Abschnitt.
|
||||
|
||||
Mehr Unterstützung? Tippe in der Gäste-App auf *Kontakt zum Support* (Einstellungen → Hilfe) und nenne deine Event-ID.
|
||||
29
docs/help/guest/index.en.md
Normal file
29
docs/help/guest/index.en.md
Normal file
@@ -0,0 +1,29 @@
|
||||
---
|
||||
title: "Guest Help Center"
|
||||
locale: en
|
||||
slug: guest-help-index
|
||||
audience: guest
|
||||
summary: "Everything attendees need to install the Fotospiel guest app, join events, and share memories."
|
||||
version_introduced: 2025.4
|
||||
status: draft
|
||||
translation_state: aligned
|
||||
last_reviewed_at: 2025-02-22
|
||||
owner: product-support@fotospiel.app
|
||||
related: []
|
||||
---
|
||||
|
||||
Welcome to the guest-focused documentation hub. Start with the Quick Start article if this is your first time using Fotospiel. Use the table below to jump to individual guides (each article has both English and German versions).
|
||||
|
||||
| Topic | Purpose | Article Slug |
|
||||
| --- | --- | --- |
|
||||
| Quick Start | Install the app, join an event, and learn the basic gestures. | `getting-started` |
|
||||
| Upload Workflow | Capture, edit, and upload photos with background sync. | `uploading-photos` |
|
||||
| Offline Mode & Sync | Keep participating when connectivity drops and ensure nothing is lost. | `offline-sync` |
|
||||
| Privacy & Support | Understand what data is stored and how to reach support. | `privacy-and-support` |
|
||||
|
||||
### How to use these docs
|
||||
- **Search**: Use the in-app search bar or offline search in the app settings screen. Keywords like “upload limit” or “share link” are supported.
|
||||
- **Language toggle**: Switch between EN/DE with the globe icon inside every article.
|
||||
- **Contextual links**: Many UI screens offer a `?` icon that deep-links directly to the relevant section here.
|
||||
|
||||
Need more help? Tap *Contact Support* inside the guest app → Settings → Help. Provide the event ID so we can assist faster.
|
||||
40
docs/help/guest/offline-sync.de.md
Normal file
40
docs/help/guest/offline-sync.de.md
Normal file
@@ -0,0 +1,40 @@
|
||||
---
|
||||
title: "Offline-Modus & Synchronisierung"
|
||||
locale: de
|
||||
slug: offline-sync
|
||||
audience: guest
|
||||
summary: "Auch ohne Netz teilnehmen, Uploads sicher zwischenspeichern und den Status prüfen."
|
||||
version_introduced: 2025.4
|
||||
requires_app_version: "^3.2.0"
|
||||
status: draft
|
||||
translation_state: aligned
|
||||
last_reviewed_at: 2025-02-22
|
||||
owner: reliability@fotospiel.app
|
||||
related:
|
||||
- slug: uploading-photos
|
||||
- slug: privacy-and-support
|
||||
---
|
||||
|
||||
## Wann lesen?
|
||||
Du rechnest mit schwacher oder fehlender Verbindung (Gebirge, Keller, Roaming). Sobald die Erst-Synchronisierung abgeschlossen ist, funktioniert die Gäste-App vollständig offline weiter.
|
||||
|
||||
## Was bleibt offline verfügbar?
|
||||
- Event-Feed (die letzten 250 Fotos) und Albumstruktur.
|
||||
- Upload-Regeln (Größenlimit, Moderationseinstellungen).
|
||||
- Entwürfe für Achievements und Sticker.
|
||||
- Auszug des Hilfecenters (Top‑10 Artikel pro Sprache), sofern du den Hilfebereich mindestens einmal online geöffnet hast.
|
||||
|
||||
## Offline-Workflow
|
||||
1. **Wie gewohnt aufnehmen.** Alles landet verschlüsselt in der lokalen Warteschlange.
|
||||
2. **Ausstehende Uploads prüfen.** Achte auf das graue Label *In Warteschlange*. Tags/Notizen lassen sich auch offline ergänzen.
|
||||
3. **Speicher überwachen.** Ein Banner warnt unter 500 MB freiem Speicher; lösche gesendete Inhalte oder nutze ein anderes Gerät.
|
||||
4. **Kurz online gehen.** Sobald irgendein Netz verfügbar ist, Fotospiel öffnen. Die Sync startet automatisch und priorisiert die ältesten Elemente.
|
||||
5. **Abschluss bestätigen.** Ein grüner Hinweis „Alle Uploads übertragen“ erscheint und der Warteschlangen-Zähler springt auf Null.
|
||||
|
||||
## Troubleshooting
|
||||
- **Bleibt trotz Netz auf „In Warteschlange“?** Flugmodus kurz aktivieren/deaktivieren und App neu öffnen, um den Service Worker zurückzusetzen.
|
||||
- **Gerätewechsel?** Offline-Warteschlangen verbleiben auf dem ursprünglichen Gerät; kein Abgleich zwischen Geräten.
|
||||
- **Energiesparmodus** kann Hintergrundsync pausieren. Für große Mengen die App im Vordergrund lassen.
|
||||
|
||||
### Weitere Hilfe
|
||||
Unter Einstellungen → Hilfe → *Diagnosedaten senden* kannst du (sobald du online bist) anonymisierte Logs plus deine Event-ID an den Support schicken.
|
||||
40
docs/help/guest/offline-sync.en.md
Normal file
40
docs/help/guest/offline-sync.en.md
Normal file
@@ -0,0 +1,40 @@
|
||||
---
|
||||
title: "Offline Mode & Sync"
|
||||
locale: en
|
||||
slug: offline-sync
|
||||
audience: guest
|
||||
summary: "Participate without coverage, queue uploads safely, and know when everything is delivered."
|
||||
version_introduced: 2025.4
|
||||
requires_app_version: "^3.2.0"
|
||||
status: draft
|
||||
translation_state: aligned
|
||||
last_reviewed_at: 2025-02-22
|
||||
owner: reliability@fotospiel.app
|
||||
related:
|
||||
- slug: uploading-photos
|
||||
- slug: privacy-and-support
|
||||
---
|
||||
|
||||
## When to read this
|
||||
You expect intermittent connectivity (mountains, cellars, roaming). The guest app is built to keep working offline as long as the initial sync completed.
|
||||
|
||||
## What stays available offline
|
||||
- Event feed (latest 250 photos) and album structure.
|
||||
- Upload rules (size limits, moderation settings).
|
||||
- Draft achievements and stickers.
|
||||
- Help center excerpt (top 10 articles per locale) if you opened Help at least once online.
|
||||
|
||||
## Offline workflow
|
||||
1. **Capture as usual.** Everything stores in the encrypted local queue.
|
||||
2. **Review pending uploads.** Look for the grey *Queued* label. Add tags/notes even while offline.
|
||||
3. **Monitor storage.** The banner warns if device storage drops below 500 MB; delete sent items or transfer to another device.
|
||||
4. **Reconnect briefly.** Once any network is available, open Fotospiel. Sync restarts automatically, prioritizing oldest items.
|
||||
5. **Confirm completion.** A green toast “All uploads delivered” appears and the queue counter returns to zero.
|
||||
|
||||
## Troubleshooting
|
||||
- **Stuck in “Queued” despite coverage?** Toggle airplane mode off/on, then reopen the app to reset the service worker.
|
||||
- **Different devices?** Offline queues stay on the original device; there’s no cross-device merge.
|
||||
- **Battery saver** might pause background sync. Keep the app in the foreground for large batches.
|
||||
|
||||
### Need more help?
|
||||
Use Settings → Help → *Send diagnostics* once you are online; support receives anonymized logs plus your event ID.
|
||||
38
docs/help/guest/privacy-and-support.de.md
Normal file
38
docs/help/guest/privacy-and-support.de.md
Normal file
@@ -0,0 +1,38 @@
|
||||
---
|
||||
title: "Datenschutz & Support"
|
||||
locale: de
|
||||
slug: privacy-and-support
|
||||
audience: guest
|
||||
summary: "Welche Daten gespeichert werden, wie du Löschungen anstößt und wie du Hilfe erreichst."
|
||||
version_introduced: 2025.4
|
||||
requires_app_version: "^3.2.0"
|
||||
status: draft
|
||||
translation_state: aligned
|
||||
last_reviewed_at: 2025-02-22
|
||||
owner: legal@fotospiel.app
|
||||
related:
|
||||
- slug: getting-started
|
||||
- slug: offline-sync
|
||||
---
|
||||
|
||||
## Welche Daten speichern wir?
|
||||
- **Fotos & Bildunterschriften**: Liegen verschlüsselt im Speicher des Kundenkontos für den vom Veranstalter definierten Zeitraum.
|
||||
- **Session-ID**: Anonymer Token vom Gerät zur Upload-Nachverfolgung; wird zurückgesetzt, wenn du die App-Daten löscht.
|
||||
- **Geräte-Metadaten**: Nur Modell + Betriebssystem-Version für Crash-Analysen. Keine Standort-, Kontakt- oder Werbe-IDs.
|
||||
|
||||
## Deine Kontrollmöglichkeiten
|
||||
1. **Einzelne Uploads löschen**: Foto öffnen → `…` → *Aus Event entfernen*. Du kannst nur eigene Inhalte löschen.
|
||||
2. **Lokalen Cache leeren**: Einstellungen → Speicher → *Gerätekopien löschen*. Entfernt Miniaturen und Entwürfe.
|
||||
3. **Komplette Löschung anfordern**: Einstellungen → Hilfe → *Datenlöschung anfragen*. E-Mail für Bestätigung angeben; wir leiten an den Event-Admin weiter.
|
||||
|
||||
## Support-Kanäle
|
||||
- **In-App**: Einstellungen → Hilfe → *Support kontaktieren*. Optional Screenshot + Diagnosepaket anhängen.
|
||||
- **E-Mail**: guests@fotospiel.app (Event-Code + Gerät nennen).
|
||||
- **Vor Ort**: Event-Personal ansprechen; sie eskalieren über die Admin-App.
|
||||
|
||||
## Antwortzeiten
|
||||
- Kritische Probleme (Uploads für gesamtes Event gestört): <15 Minuten.
|
||||
- Individuelle Lösch- oder Datenschutzanfragen: innerhalb von 48 Stunden.
|
||||
|
||||
### Weitere Hilfe
|
||||
Rechtsseiten (Impressum, Datenschutz, AGB) findest du unter Einstellungen → Rechtliches. Für kundenspezifische Regelungen kontaktiere den Veranstalter direkt.
|
||||
38
docs/help/guest/privacy-and-support.en.md
Normal file
38
docs/help/guest/privacy-and-support.en.md
Normal file
@@ -0,0 +1,38 @@
|
||||
---
|
||||
title: "Privacy & Getting Help"
|
||||
locale: en
|
||||
slug: privacy-and-support
|
||||
audience: guest
|
||||
summary: "Understand what data is stored, how to request deletions, and how to contact support."
|
||||
version_introduced: 2025.4
|
||||
requires_app_version: "^3.2.0"
|
||||
status: draft
|
||||
translation_state: aligned
|
||||
last_reviewed_at: 2025-02-22
|
||||
owner: legal@fotospiel.app
|
||||
related:
|
||||
- slug: getting-started
|
||||
- slug: offline-sync
|
||||
---
|
||||
|
||||
## Data we store
|
||||
- **Photos & captions**: Stored on the customer account’s encrypted storage for the retention period defined by the event organizer.
|
||||
- **Session ID**: Anonymous token generated on your device for upload tracking; resets if you clear app storage.
|
||||
- **Device metadata**: Only model + OS version, used for crash insights. No location, contacts, or advertising IDs.
|
||||
|
||||
## Your controls
|
||||
1. **Delete individual uploads**: Open the photo → tap `…` → *Remove from event*. You can delete only your own items.
|
||||
2. **Erase local cache**: Settings → Storage → *Clear device copies*. This removes cached thumbnails and drafts.
|
||||
3. **Request full erasure**: Use Settings → Help → *Request data deletion*. Provide email for confirmation; we forward the request to the event admin who controls the customer account.
|
||||
|
||||
## Support channels
|
||||
- **In-app**: Settings → Help → *Contact support*. Includes optional screenshot + diagnostics bundle.
|
||||
- **Email**: guests@fotospiel.app (mention event code + device model).
|
||||
- **On-site**: Ask the event staff to escalate via the customer admin app.
|
||||
|
||||
## Response times
|
||||
- Critical issues (uploads failing for entire event): <15 minutes.
|
||||
- Individual deletion or privacy questions: within 48 hours.
|
||||
|
||||
### Need more help?
|
||||
Review the public legal pages (Impressum, Privacy, AGB) under Settings → Legal, or contact the event organizer directly for customer-specific policies.
|
||||
39
docs/help/guest/uploading-photos.de.md
Normal file
39
docs/help/guest/uploading-photos.de.md
Normal file
@@ -0,0 +1,39 @@
|
||||
---
|
||||
title: "Fotos aufnehmen & hochladen"
|
||||
locale: de
|
||||
slug: uploading-photos
|
||||
audience: guest
|
||||
summary: "Integrierte Kamera nutzen, Aufnahmen bearbeiten und bei Funklöchern auf Hintergrund-Sync setzen."
|
||||
version_introduced: 2025.4
|
||||
requires_app_version: "^3.2.0"
|
||||
status: draft
|
||||
translation_state: aligned
|
||||
last_reviewed_at: 2025-02-22
|
||||
owner: guest-success@fotospiel.app
|
||||
related:
|
||||
- slug: getting-started
|
||||
- slug: offline-sync
|
||||
---
|
||||
|
||||
## Wann lesen?
|
||||
Du bist bereits einem Event beigetreten und möchtest verstehen, wie der Aufnahme-Workflow funktioniert, welche Qualitätsgrenzen gelten und was bei Verbindungsproblemen passiert.
|
||||
|
||||
## Schritt für Schritt
|
||||
1. **Fotospiel-Kamera öffnen.** Vom Feed nach oben wischen oder auf das Auslösersymbol tippen.
|
||||
2. **Aufnahmemodus wählen.**
|
||||
- *Einzelfoto*: Standardmodus mit HDR-Anpassung.
|
||||
- *Serie*: Auslöser halten, bis zu 5 Bilder; die App wählt automatisch das schärfste.
|
||||
- *Import*: Miniatur antippen, um vorhandene Fotos/Screenshots zu laden.
|
||||
3. **Anpassungen vornehmen.** Zuschneiden, drehen oder optionalen Text-Sticker hinzufügen. Alles passiert lokal. Mit *Speichern* bestätigen.
|
||||
4. **Album & Tags setzen.** Ordne das Foto dem passenden Kapitel (z. B. Trauung) zu und füge bei Bedarf Stimmungstags an. Alben sind offline vorhanden.
|
||||
5. **Upload-Warteschlange prüfen.** Offene Elemente erscheinen im Tab `Uploads` mit Status-Badge: *In Warteschlange*, *Sendet* oder *Erfordert Aktion*.
|
||||
6. **Hintergrund-Sync abwarten.** Beim Schließen sendet die App noch ca. 30 Sekunden (Systemlimit). Später öffnen setzt den Upload automatisch fort.
|
||||
7. **Fehler beheben.** Warnsymbol tippen → *Jetzt erneut versuchen* oder *Löschen*. Häufige Ursachen: Flugmodus, entzogene Berechtigungen, Speicher voll.
|
||||
|
||||
## Tipps
|
||||
- Fotos übernehmen die Gerätezeit. Unter "Event-Zeit verwenden" kannst du die Metadaten angleichen.
|
||||
- Markiere bis zu 10 wartende Uploads gleichzeitig zum Löschen oder erneuten Senden.
|
||||
- Likes und Kommentare synchronisieren getrennt und blockieren den Foto-Upload nicht.
|
||||
|
||||
### Weitere Hilfe
|
||||
Siehe `offline-sync` für längere Offline-Phasen oder kontaktiere den Support unter Einstellungen → Hilfe.
|
||||
39
docs/help/guest/uploading-photos.en.md
Normal file
39
docs/help/guest/uploading-photos.en.md
Normal file
@@ -0,0 +1,39 @@
|
||||
---
|
||||
title: "Capture & Upload Photos"
|
||||
locale: en
|
||||
slug: uploading-photos
|
||||
audience: guest
|
||||
summary: "Use the built-in camera, edit shots, and rely on background sync if connectivity drops."
|
||||
version_introduced: 2025.4
|
||||
requires_app_version: "^3.2.0"
|
||||
status: draft
|
||||
translation_state: aligned
|
||||
last_reviewed_at: 2025-02-22
|
||||
owner: guest-success@fotospiel.app
|
||||
related:
|
||||
- slug: getting-started
|
||||
- slug: offline-sync
|
||||
---
|
||||
|
||||
## When to read this
|
||||
You already joined an event and want to understand the capture workflow, quality limits, and what happens if uploads fail or the connection disappears.
|
||||
|
||||
## Step-by-step
|
||||
1. **Open the Fotospiel camera.** Swipe up from the timeline or tap the shutter icon.
|
||||
2. **Pick a capture mode.**
|
||||
- *Single shot*: default mode with HDR tuning.
|
||||
- *Burst*: hold the shutter to capture up to 5 frames; the app picks the sharpest by default.
|
||||
- *Import*: tap the gallery thumbnail to select existing photos/screenshots.
|
||||
3. **Apply adjustments.** Crop, rotate, or add the optional text sticker. All edits happen on-device. Tap *Save* to confirm.
|
||||
4. **Choose the album & tags.** Assign to the correct chapter (e.g., Ceremony) and optionally add mood tags. Albums are cached offline.
|
||||
5. **Review upload queue.** Pending items appear in the `Uploads` tab with a status pill: *Queued*, *Sending*, or *Needs attention*.
|
||||
6. **Let background sync finish.** Closing the app keeps uploads going for ~30 seconds (platform limit). Reopen later to resume automatically.
|
||||
7. **Fix failed uploads.** Tap the warning icon → *Retry now* or *Delete*. Common issues: airplane mode, revoked permissions, storage full.
|
||||
|
||||
## Tips
|
||||
- Photos inherit the device timestamp; if clocks differ from event time, toggling “Use event time” adjusts metadata.
|
||||
- Batch-select up to 10 pending uploads to delete or retry at once.
|
||||
- Likes and comments sync separately and don’t block photo uploads.
|
||||
|
||||
### Need more help?
|
||||
Read `offline-sync` for long offline stretches or contact support from Settings → Help.
|
||||
30
docs/help/templates/article.de.md
Normal file
30
docs/help/templates/article.de.md
Normal file
@@ -0,0 +1,30 @@
|
||||
---
|
||||
title: "<Titel>"
|
||||
locale: de
|
||||
slug: <slug>
|
||||
audience: guest
|
||||
summary: "Kurzer Teaser für Listenansichten."
|
||||
version_introduced: 2025.4
|
||||
requires_app_version: "^3.2.0"
|
||||
status: draft
|
||||
translation_state: draft
|
||||
last_reviewed_at: 2025-02-22
|
||||
owner: <Team oder Person>
|
||||
related:
|
||||
- slug: <anderer-slug>
|
||||
---
|
||||
|
||||
> Halte Absätze kurz (max. 3 Sätze) und nutze nummerierte Listen für Abläufe.
|
||||
|
||||
## Wann ist dieser Artikel relevant?
|
||||
Szenario, Voraussetzungen und erwartetes Ergebnis beschreiben.
|
||||
|
||||
## Schritte
|
||||
1. Schrittbeschreibung
|
||||
2. …
|
||||
|
||||
### Tipps
|
||||
- Optionale Hinweise, Varianten oder FAQs.
|
||||
|
||||
### Weitere Hilfe
|
||||
Auf Support-Optionen oder verknüpfte Artikel verweisen.
|
||||
30
docs/help/templates/article.en.md
Normal file
30
docs/help/templates/article.en.md
Normal file
@@ -0,0 +1,30 @@
|
||||
---
|
||||
title: "<Title>"
|
||||
locale: en
|
||||
slug: <slug>
|
||||
audience: guest
|
||||
summary: "1–2 sentence preview for list views."
|
||||
version_introduced: 2025.4
|
||||
requires_app_version: "^3.2.0"
|
||||
status: draft
|
||||
translation_state: draft
|
||||
last_reviewed_at: 2025-02-22
|
||||
owner: <team or person>
|
||||
related:
|
||||
- slug: <other-slug>
|
||||
---
|
||||
|
||||
> Keep paragraphs short (max ~3 sentences) and favor ordered lists for procedures.
|
||||
|
||||
## When to read this
|
||||
Explain the scenario, prerequisites, and expected outcome.
|
||||
|
||||
## Steps
|
||||
1. Step explaination
|
||||
2. …
|
||||
|
||||
### Tips
|
||||
- Optional tips, variations, or FAQs.
|
||||
|
||||
### Need more help?
|
||||
Point to support options or related articles.
|
||||
102
docs/photobooth_ftp/README.md
Normal file
102
docs/photobooth_ftp/README.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# Photobooth FTP Ingestion
|
||||
|
||||
This guide explains how to operate the Photobooth FTP workflow end‑to‑end: provisioning FTP users for tenants, running the ingest pipeline, and exposing photobooth photos inside the Guest PWA.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
1. **vsftpd container** (port `2121`) accepts uploads into a shared volume (default `/var/www/storage/app/photobooth`). Each event receives isolated credentials and a dedicated directory.
|
||||
2. **Control Service** (REST) provisions FTP accounts. Laravel calls it during enable/rotate/disable actions.
|
||||
3. **Photobooth settings** (Filament SuperAdmin) define global port, rate limit, expiry grace, and Control Service connection.
|
||||
4. **Ingest command** copies uploaded files into the event’s storage disk, generates thumbnails, records `photos.ingest_source = photobooth`, and respects package quotas.
|
||||
5. **Guest PWA filter** consumes `/api/v1/events/{token}/photos?filter=photobooth` to render the “Fotobox” tab.
|
||||
|
||||
```
|
||||
Photobooth -> FTP (vsftpd) -> photobooth disk
|
||||
photobooth:ingest (queue/scheduler)
|
||||
-> Event media storage (public disk/S3)
|
||||
-> packages_usage, thumbnails, security scan
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Add the following to `.env` (already scaffolded in `.env.example`):
|
||||
|
||||
```env
|
||||
PHOTOBOOTH_CONTROL_BASE_URL=https://control.internal/api
|
||||
PHOTOBOOTH_CONTROL_TOKEN=your-control-token
|
||||
PHOTOBOOTH_CONTROL_TIMEOUT=5
|
||||
|
||||
PHOTOBOOTH_FTP_HOST=ftp.internal
|
||||
PHOTOBOOTH_FTP_PORT=2121
|
||||
|
||||
PHOTOBOOTH_USERNAME_PREFIX=pb
|
||||
PHOTOBOOTH_USERNAME_LENGTH=8
|
||||
PHOTOBOOTH_PASSWORD_LENGTH=8
|
||||
|
||||
PHOTOBOOTH_RATE_LIMIT_PER_MINUTE=20
|
||||
PHOTOBOOTH_EXPIRY_GRACE_DAYS=1
|
||||
|
||||
PHOTOBOOTH_IMPORT_DISK=photobooth
|
||||
PHOTOBOOTH_IMPORT_ROOT=/var/www/storage/app/photobooth
|
||||
PHOTOBOOTH_IMPORT_MAX_FILES=50
|
||||
PHOTOBOOTH_ALLOWED_EXTENSIONS=jpg,jpeg,png,webp
|
||||
```
|
||||
|
||||
### Filesystem Disk
|
||||
|
||||
`config/filesystems.php` registers a `photobooth` disk that must point to the shared volume where vsftpd writes files. Mount the same directory inside both the FTP container and the Laravel app container.
|
||||
|
||||
## Control Service Contract
|
||||
|
||||
Laravel expects the Control Service to expose:
|
||||
|
||||
```
|
||||
POST /users { username, password, path, rate_limit_per_minute, expires_at, ftp_port }
|
||||
POST /users/{username}/rotate { password, rate_limit_per_minute, expires_at }
|
||||
DELETE /users/{username}
|
||||
POST /config { ftp_port, rate_limit_per_minute, expiry_grace_days }
|
||||
```
|
||||
|
||||
Authentication is provided via `PHOTOBOOTH_CONTROL_TOKEN` (Bearer token).
|
||||
|
||||
## Scheduler & Commands
|
||||
|
||||
| Command | Purpose | Default schedule |
|
||||
|---------|---------|------------------|
|
||||
| `photobooth:ingest [--event=ID] [--max-files=N]` | Pulls files from the Photobooth disk and imports them into the event storage. | every 5 minutes |
|
||||
| `photobooth:cleanup-expired` | De-provisions FTP accounts after their expiry. | hourly |
|
||||
|
||||
You can run the ingest job manually for a specific event:
|
||||
|
||||
```bash
|
||||
php artisan photobooth:ingest --event=123 --max-files=20
|
||||
```
|
||||
|
||||
## Tenant Admin UX
|
||||
|
||||
Inside the Event Admin PWA, go to **Event → Fotobox-Uploads** to:
|
||||
|
||||
1. Enable/disable the Photobooth link.
|
||||
2. Rotate credentials (max 10-char usernames, 8-char passwords).
|
||||
3. View rate limit + expiry info and copy the ftp:// link.
|
||||
|
||||
## Guest PWA Filter
|
||||
|
||||
The Guest gallery now exposes a “Fotobox” tab (both preview card and full gallery). API usage:
|
||||
|
||||
```
|
||||
GET /api/v1/events/{token}/photos?filter=photobooth
|
||||
Headers: X-Device-Id (optional)
|
||||
```
|
||||
|
||||
Response items contain `ingest_source`, allowing the frontend to toggle photobooth-only views.
|
||||
|
||||
## Operational Checklist
|
||||
|
||||
1. **Set env vars** from above and restart the app.
|
||||
2. **Ensure vsftpd + Control Service** are deployed; verify port 2121 and REST endpoint connectivity.
|
||||
3. **Mount shared volume** to `/var/www/storage/app/photobooth` (or update `PHOTOBOOTH_IMPORT_ROOT` + `filesystems.disks.photobooth.root`).
|
||||
4. **Run migrations** (`php artisan migrate`) to create settings/event columns.
|
||||
5. **Seed default storage target** (e.g., `MediaStorageTarget::create([... 'key' => 'public', ...])`) in non-test environments if not present.
|
||||
6. **Verify scheduler** (Horizon or cron) is running commands `photobooth:ingest` and `photobooth:cleanup-expired`.
|
||||
7. **Test end-to-end**: enable Photobooth on a staging event, upload a file via FTP, wait for ingest, and confirm it appears under the Fotobox filter in the PWA.
|
||||
97
docs/photobooth_ftp/control_service.md
Normal file
97
docs/photobooth_ftp/control_service.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# Photobooth Control Service API
|
||||
|
||||
The control service is a lightweight sidecar responsible for provisioning vsftpd accounts. Laravel talks to it via REST whenever an Event Admin enables, rotates, or disables the Photobooth feature.
|
||||
|
||||
## Authentication
|
||||
|
||||
- **Scheme:** Bearer token.
|
||||
- **Header:** `Authorization: Bearer ${PHOTOBOOTH_CONTROL_TOKEN}`.
|
||||
- **Timeout:** Configurable via `PHOTOBOOTH_CONTROL_TIMEOUT` (default 5 s).
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method & Path | Description |
|
||||
|---------------|-------------|
|
||||
| `POST /users` | Create a new FTP account for an event. |
|
||||
| `POST /users/{username}/rotate` | Rotate credentials / extend expiry for an existing user. |
|
||||
| `DELETE /users/{username}` | Remove an FTP account (called when an event disables Photobooth or expires). |
|
||||
| `POST /config` | Optionally push global config changes (port, rate-limit, expiry grace) to the control service. |
|
||||
|
||||
### `POST /users`
|
||||
|
||||
```json
|
||||
{
|
||||
"username": "pbA12345",
|
||||
"password": "F4P9K2QX",
|
||||
"path": "tenant-slug/123",
|
||||
"rate_limit_per_minute": 20,
|
||||
"expires_at": "2025-06-15T22:59:59Z",
|
||||
"ftp_port": 2121,
|
||||
"allowed_ip_ranges": ["1.2.3.4/32"],
|
||||
"metadata": {
|
||||
"event_id": 123,
|
||||
"tenant_id": 5
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** `201 Created` with `{ "ok": true }`. On failure return 4xx/5xx JSON with `error.code` + `message`.
|
||||
|
||||
Implementation tips:
|
||||
|
||||
- Ensure the system user or virtual user’s home directory is set to the provided `path` (prefixed with the shared Photobooth root).
|
||||
- Apply the rate limit token-bucket before writing to disk (or integrate with HAProxy).
|
||||
- Store `expires_at` and automatically disable the account when reached (in addition to Laravel’s scheduled cleanup).
|
||||
|
||||
### `POST /users/{username}/rotate`
|
||||
|
||||
```json
|
||||
{
|
||||
"password": "K9M4T6QZ",
|
||||
"rate_limit_per_minute": 20,
|
||||
"expires_at": "2025-06-16T22:59:59Z"
|
||||
}
|
||||
```
|
||||
|
||||
- Rotate the password atomically and respond with `{ "ok": true }`.
|
||||
- If username does not exist return `404` with a descriptive message so Laravel can re-provision.
|
||||
|
||||
### `DELETE /users/{username}`
|
||||
|
||||
No request body. Delete or disable the FTP account, removing access to the assigned directory.
|
||||
|
||||
### `POST /config`
|
||||
|
||||
Optional hook used when SuperAdmins change defaults:
|
||||
|
||||
```json
|
||||
{
|
||||
"ftp_port": 2121,
|
||||
"rate_limit_per_minute": 20,
|
||||
"expiry_grace_days": 1
|
||||
}
|
||||
```
|
||||
|
||||
Use this to reload vsftpd or adjust proxy rules without redeploying the control service.
|
||||
|
||||
## Error Contract
|
||||
|
||||
Return JSON structured as:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "user_exists",
|
||||
"message": "Username already provisioned",
|
||||
"context": { "username": "pbA12345" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Laravel treats any non-2xx as fatal and logs the payload (sans password). Prefer descriptive `code` values: `user_exists`, `user_not_found`, `rate_limit_violation`, `invalid_payload`, etc.
|
||||
|
||||
## Observability
|
||||
|
||||
- Emit structured logs for every create/rotate/delete with event + tenant IDs.
|
||||
- Expose `/health` so Laravel (or uptime monitors) can verify connectivity.
|
||||
- Consider metrics (e.g., Prometheus) for active accounts, rotations, and failures.
|
||||
53
docs/photobooth_ftp/ops_playbook.md
Normal file
53
docs/photobooth_ftp/ops_playbook.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# Photobooth Operations Playbook
|
||||
|
||||
Use this checklist when bringing Photobooth FTP online for a tenant or debugging ingest issues.
|
||||
|
||||
## 1. Provisioning Flow
|
||||
|
||||
1. **SuperAdmin config** – set defaults in Filament → Platform Management → Photobooth Settings.
|
||||
2. **Tenant enablement** – Event Admin opens the event → Fotobox-Uploads → “Photobooth aktivieren”.
|
||||
3. Laravel generates credentials and calls the control service (`POST /users`).
|
||||
4. vsftpd accepts uploads at `ftp://username:password@HOST:PORT/`.
|
||||
5. `photobooth:ingest` copies files into the hot storage disk and applies moderation/security pipelines.
|
||||
|
||||
## 2. Troubleshooting
|
||||
|
||||
| Symptom | Action |
|
||||
|---------|--------|
|
||||
| Tenant’s Photobooth page shows “Deaktiviert” immediately | Check `storage/logs/laravel.log` for control-service errors; re-run `photobooth:ingest --event=ID -vv`. |
|
||||
| Files remain under `/storage/app/photobooth/<tenant>/<event>` | Ensure scheduler (Horizon/cron) runs `photobooth:ingest`; run manual command to force ingestion. |
|
||||
| Photos missing from guest “Fotobox” tab | Confirm `photos.ingest_source = photobooth` and that `/api/v1/events/{token}/photos?filter=photobooth` returns data. |
|
||||
| Rate-limit complaints | Inspect control service logs; adjust `PHOTOBOOTH_RATE_LIMIT_PER_MINUTE` and re-save settings (fires `/config`). |
|
||||
| Credentials leaked/compromised | Click “Zugang neu generieren” in Event Admin; optional `php artisan photobooth:cleanup-expired --event=ID` to force deletion before expiry. |
|
||||
|
||||
## 3. Command Reference
|
||||
|
||||
```bash
|
||||
# Manually ingest pending files for a single event
|
||||
php artisan photobooth:ingest --event=123 --max-files=100
|
||||
|
||||
# Check ingest for all active events (dry run)
|
||||
php artisan photobooth:ingest --max-files=10
|
||||
|
||||
# Remove expired accounts (safe to run ad hoc)
|
||||
php artisan photobooth:cleanup-expired
|
||||
```
|
||||
|
||||
## 4. Pre-flight Checklist for New Deployments
|
||||
|
||||
1. `php artisan migrate`
|
||||
2. Configure `.env` Photobooth variables.
|
||||
3. Mount shared Photobooth volume in all containers (FTP + Laravel).
|
||||
4. Verify `MediaStorageTarget` records exist (hot target pointing at the hot disk).
|
||||
5. Seed baseline emotions (Photobooth ingest assigns `emotion_id` from existing rows).
|
||||
6. Confirm scheduler runs (Horizon supervisor or system cron).
|
||||
|
||||
## 5. Incident Response
|
||||
|
||||
1. **Identify scope** – which events/tenants are affected? Check ingestion logs for specific usernames/path.
|
||||
2. **Quarantine** – disable the Photobooth toggle for impacted events via Admin UI.
|
||||
3. **Remediate** – fix FTP/control issues, rotate credentials, run `photobooth:ingest`.
|
||||
4. **Audit** – review `photobooth_metadata` on events and `photos.ingest_source`.
|
||||
5. **Communicate** – notify tenant admins via in-app message or email template referencing incident ID.
|
||||
|
||||
Keep this playbook updated whenever infra/process changes. PRs to `/docs/photobooth_ftp` welcome.
|
||||
@@ -115,3 +115,15 @@ When deploying new code:
|
||||
2. Run migrations & seeders.
|
||||
3. Recreate worker/horizon containers: `docker compose up -d --force-recreate queue-worker media-storage-worker horizon`.
|
||||
4. Tail logs to confirm workers boot cleanly and start consuming jobs.
|
||||
|
||||
### 8. Running inside Coolify
|
||||
|
||||
If you host Fotospiel on Coolify:
|
||||
|
||||
- Create separate Coolify “services” for each worker type using the same image and command snippets above (`queue-worker.sh default`, `media-storage`, etc.).
|
||||
- Attach the same environment variables and storage volumes defined for the main app.
|
||||
- Use Coolify’s “One-off command” feature to run migrations or `queue:retry`.
|
||||
- Expose the Horizon service through Coolify’s HTTP proxy (or keep it internal and access via SSH tunnel).
|
||||
- Enable health checks so Coolify restarts workers automatically if they exit unexpectedly.
|
||||
|
||||
These services can be observed and restarted from Coolify’s dashboard; the upcoming SuperAdmin integration will surface the same metrics/actions through a dedicated Filament widget.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "Fotospiel Tenant Admin",
|
||||
"name": "Fotospiel Customer Admin",
|
||||
"short_name": "Fotospiel Admin",
|
||||
"id": "/event-admin",
|
||||
"start_url": "/event-admin/",
|
||||
|
||||
@@ -98,6 +98,7 @@ export type TenantPhoto = {
|
||||
likes_count: number;
|
||||
uploaded_at: string;
|
||||
uploader_name: string | null;
|
||||
ingest_source?: string | null;
|
||||
caption?: string | null;
|
||||
};
|
||||
|
||||
@@ -114,6 +115,40 @@ export type EventStats = {
|
||||
pending_photos?: number;
|
||||
};
|
||||
|
||||
export type PhotoboothStatus = {
|
||||
enabled: boolean;
|
||||
status: string | null;
|
||||
username: string | null;
|
||||
password: string | null;
|
||||
path: string | null;
|
||||
ftp_url: string | null;
|
||||
expires_at: string | null;
|
||||
rate_limit_per_minute: number;
|
||||
ftp: {
|
||||
host: string | null;
|
||||
port: number;
|
||||
require_ftps: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type HelpCenterArticleSummary = {
|
||||
slug: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
updated_at?: string;
|
||||
status?: string;
|
||||
translation_state?: string;
|
||||
related?: Array<{ slug: string }>;
|
||||
};
|
||||
|
||||
export type HelpCenterArticle = HelpCenterArticleSummary & {
|
||||
body_html?: string;
|
||||
body_markdown?: string;
|
||||
owner?: string;
|
||||
requires_app_version?: string | null;
|
||||
version_introduced?: string;
|
||||
};
|
||||
|
||||
export type PaginationMeta = {
|
||||
current_page: number;
|
||||
last_page: number;
|
||||
@@ -184,6 +219,60 @@ export async function fetchOnboardingStatus(): Promise<TenantOnboardingStatus |
|
||||
}
|
||||
}
|
||||
|
||||
function resolveHelpLocale(locale?: string): 'de' | 'en' {
|
||||
if (!locale) {
|
||||
return 'de';
|
||||
}
|
||||
const normalized = locale.toLowerCase().split('-')[0];
|
||||
return normalized === 'en' ? 'en' : 'de';
|
||||
}
|
||||
|
||||
export async function fetchHelpCenterArticles(locale?: string): Promise<HelpCenterArticleSummary[]> {
|
||||
const resolvedLocale = resolveHelpLocale(locale);
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({ audience: 'admin', locale: resolvedLocale });
|
||||
const response = await authorizedFetch(`/api/v1/help?${params.toString()}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch help articles');
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as { data?: HelpCenterArticleSummary[] };
|
||||
return Array.isArray(payload?.data) ? payload.data : [];
|
||||
} catch (error) {
|
||||
const message = i18n.t('common.errors.generic', 'Etwas ist schiefgelaufen.');
|
||||
emitApiErrorEvent({ message, code: 'help.fetch_list_failed' });
|
||||
console.error('[HelpApi] Failed to fetch help articles', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchHelpCenterArticle(slug: string, locale?: string): Promise<HelpCenterArticle> {
|
||||
const resolvedLocale = resolveHelpLocale(locale);
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({ audience: 'admin', locale: resolvedLocale });
|
||||
const response = await authorizedFetch(`/api/v1/help/${encodeURIComponent(slug)}?${params.toString()}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch help article');
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as { data?: HelpCenterArticle };
|
||||
if (!payload?.data) {
|
||||
throw new Error('Empty help article response');
|
||||
}
|
||||
|
||||
return payload.data;
|
||||
} catch (error) {
|
||||
const message = i18n.t('common.errors.generic', 'Etwas ist schiefgelaufen.');
|
||||
emitApiErrorEvent({ message, code: 'help.fetch_detail_failed' });
|
||||
console.error('[HelpApi] Failed to fetch help article', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export type TenantPackageSummary = {
|
||||
id: number;
|
||||
package_id: number;
|
||||
@@ -883,6 +972,38 @@ function eventEndpoint(slug: string): string {
|
||||
return `/api/v1/tenant/events/${encodeURIComponent(slug)}`;
|
||||
}
|
||||
|
||||
function photoboothEndpoint(slug: string): string {
|
||||
return `${eventEndpoint(slug)}/photobooth`;
|
||||
}
|
||||
|
||||
function normalizePhotoboothStatus(payload: JsonValue): PhotoboothStatus {
|
||||
const ftp = (payload.ftp ?? {}) as JsonValue;
|
||||
|
||||
return {
|
||||
enabled: Boolean(payload.enabled),
|
||||
status: typeof payload.status === 'string' ? payload.status : null,
|
||||
username: typeof payload.username === 'string' ? payload.username : null,
|
||||
password: typeof payload.password === 'string' ? payload.password : null,
|
||||
path: typeof payload.path === 'string' ? payload.path : null,
|
||||
ftp_url: typeof payload.ftp_url === 'string' ? payload.ftp_url : null,
|
||||
expires_at: typeof payload.expires_at === 'string' ? payload.expires_at : null,
|
||||
rate_limit_per_minute: Number(payload.rate_limit_per_minute ?? ftp.rate_limit_per_minute ?? 0),
|
||||
ftp: {
|
||||
host: typeof ftp.host === 'string' ? ftp.host : null,
|
||||
port: Number(ftp.port ?? payload.ftp_port ?? 0) || 0,
|
||||
require_ftps: Boolean(ftp.require_ftps ?? payload.require_ftps),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function requestPhotoboothStatus(slug: string, path = '', init: RequestInit = {}, errorMessage = 'Failed to fetch photobooth status'): Promise<PhotoboothStatus> {
|
||||
const response = await authorizedFetch(`${photoboothEndpoint(slug)}${path}`, init);
|
||||
const payload = await jsonOrThrow<JsonValue | { data: JsonValue }>(response, errorMessage);
|
||||
const body = (payload as { data?: JsonValue }).data ?? (payload as JsonValue);
|
||||
|
||||
return normalizePhotoboothStatus(body ?? {});
|
||||
}
|
||||
|
||||
export async function getEvents(options?: { force?: boolean }): Promise<TenantEvent[]> {
|
||||
return cachedFetch(
|
||||
CacheKeys.events,
|
||||
@@ -1118,6 +1239,22 @@ export async function getEventToolkit(slug: string): Promise<EventToolkit> {
|
||||
return toolkit;
|
||||
}
|
||||
|
||||
export async function getEventPhotoboothStatus(slug: string): Promise<PhotoboothStatus> {
|
||||
return requestPhotoboothStatus(slug, '', {}, 'Failed to load photobooth status');
|
||||
}
|
||||
|
||||
export async function enableEventPhotobooth(slug: string): Promise<PhotoboothStatus> {
|
||||
return requestPhotoboothStatus(slug, '/enable', { method: 'POST' }, 'Failed to enable photobooth access');
|
||||
}
|
||||
|
||||
export async function rotateEventPhotobooth(slug: string): Promise<PhotoboothStatus> {
|
||||
return requestPhotoboothStatus(slug, '/rotate', { method: 'POST' }, 'Failed to rotate credentials');
|
||||
}
|
||||
|
||||
export async function disableEventPhotobooth(slug: string): Promise<PhotoboothStatus> {
|
||||
return requestPhotoboothStatus(slug, '/disable', { method: 'POST' }, 'Failed to disable photobooth access');
|
||||
}
|
||||
|
||||
export async function submitTenantFeedback(payload: {
|
||||
category: string;
|
||||
sentiment?: 'positive' | 'neutral' | 'negative';
|
||||
|
||||
@@ -30,3 +30,4 @@ export const ADMIN_EVENT_MEMBERS_PATH = (slug: string): string => adminPath(`/ev
|
||||
export const ADMIN_EVENT_TASKS_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/tasks`);
|
||||
export const ADMIN_EVENT_TOOLKIT_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/toolkit`);
|
||||
export const ADMIN_EVENT_INVITES_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/invites`);
|
||||
export const ADMIN_EVENT_PHOTOBOOTH_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/photobooth`);
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"panel_title": "Team Login für Fotospiel",
|
||||
"panel_copy": "Melde dich an, um Events zu planen, Fotos zu moderieren und Aufgaben im Fotospiel-Team zu koordinieren.",
|
||||
"actions_title": "Wähle deine Anmeldemethode",
|
||||
"actions_copy": "Greife sicher mit deinem Fotospiel-Login oder deinem Google-Konto auf das Tenant-Dashboard zu.",
|
||||
"actions_copy": "Greife sicher mit deinem Fotospiel-Login oder deinem Google-Konto auf das Kunden-Dashboard zu.",
|
||||
"cta": "Mit Fotospiel-Login fortfahren",
|
||||
"google_cta": "Mit Google anmelden",
|
||||
"open_account_login": "Konto-Login öffnen",
|
||||
@@ -24,12 +24,12 @@
|
||||
"oauth_errors": {
|
||||
"login_required": "Bitte melde dich zuerst in deinem Fotospiel-Konto an.",
|
||||
"invalid_request": "Die Login-Anfrage war ungültig. Bitte versuche es erneut.",
|
||||
"invalid_client": "Die verknüpfte Tenant-App wurde nicht gefunden. Wende dich an den Support, falls das Problem bleibt.",
|
||||
"invalid_client": "Die verknüpfte Kunden-App wurde nicht gefunden. Wende dich an den Support, falls das Problem bleibt.",
|
||||
"invalid_redirect": "Die angegebene Weiterleitungsadresse ist für diese App nicht hinterlegt.",
|
||||
"invalid_scope": "Die App fordert Berechtigungen an, die nicht freigegeben sind.",
|
||||
"tenant_mismatch": "Du hast keinen Zugriff auf den Tenant, der diese Anmeldung angefordert hat.",
|
||||
"tenant_mismatch": "Du hast keinen Zugriff auf das Kundenkonto, das diese Anmeldung angefordert hat.",
|
||||
"google_failed": "Die Anmeldung mit Google war nicht erfolgreich. Bitte versuche es erneut oder wähle eine andere Methode.",
|
||||
"google_no_match": "Wir konnten dieses Google-Konto keinem Tenant-Admin zuordnen. Bitte melde dich mit Fotospiel-Zugangsdaten an."
|
||||
"google_no_match": "Wir konnten dieses Google-Konto keinem Kunden-Admin zuordnen. Bitte melde dich mit Fotospiel-Zugangsdaten an."
|
||||
},
|
||||
"return_hint": "Nach dem Anmelden leiten wir dich automatisch zurück.",
|
||||
"support": "Du brauchst Zugriff? Kontaktiere dein Event-Team oder schreib uns an support@fotospiel.de.",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"app": {
|
||||
"brand": "Fotospiel Tenant Admin",
|
||||
"brand": "Fotospiel Kunden-Admin",
|
||||
"languageSwitch": "Sprache",
|
||||
"userMenu": "Konto",
|
||||
"help": "FAQ & Hilfe",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"guidedSetup": "Guided Setup"
|
||||
},
|
||||
"welcome": {
|
||||
"fallbackName": "Tenant-Admin",
|
||||
"fallbackName": "Kunden-Admin",
|
||||
"greeting": "Hallo {{name}}!",
|
||||
"subtitle": "Behalte deine Events, Pakete und Aufgaben im Blick."
|
||||
},
|
||||
@@ -24,7 +24,7 @@
|
||||
},
|
||||
"overview": {
|
||||
"title": "Kurzer Überblick",
|
||||
"description": "Wichtigste Kennzahlen deines Tenants auf einen Blick.",
|
||||
"description": "Wichtigste Kennzahlen deines Nutzerkontos auf einen Blick.",
|
||||
"noPackage": "Kein aktives Paket",
|
||||
"stats": {
|
||||
"activePackage": "Aktives Paket",
|
||||
@@ -125,7 +125,7 @@
|
||||
},
|
||||
"faq": {
|
||||
"title": "FAQ & Hilfe",
|
||||
"subtitle": "Antworten und Hinweise rund um den Tenant Admin.",
|
||||
"subtitle": "Antworten und Hinweise rund um den Kunden-Admin.",
|
||||
"intro": {
|
||||
"title": "Was dich erwartet",
|
||||
"description": "Wir sammeln aktuell Feedback und erweitern dieses Hilfe-Center Schritt für Schritt."
|
||||
@@ -148,6 +148,26 @@
|
||||
"contact": "Support kontaktieren"
|
||||
}
|
||||
},
|
||||
"helpCenter": {
|
||||
"title": "Hilfe & Dokumentation",
|
||||
"subtitle": "Geführte Anleitungen und Troubleshooting für Event-Admins.",
|
||||
"search": {
|
||||
"placeholder": "Suche nach Thema oder Stichwort"
|
||||
},
|
||||
"list": {
|
||||
"empty": "Keine Artikel gefunden.",
|
||||
"error": "Hilfe konnte nicht geladen werden.",
|
||||
"retry": "Erneut versuchen",
|
||||
"updated": "Aktualisiert {{date}}"
|
||||
},
|
||||
"article": {
|
||||
"placeholder": "Wähle links einen Artikel aus, um Details zu sehen.",
|
||||
"loading": "Artikel wird geladen...",
|
||||
"error": "Artikel konnte nicht geladen werden.",
|
||||
"updated": "Aktualisiert am {{date}}",
|
||||
"related": "Verwandte Artikel"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"actions": {
|
||||
"newEvent": "Neues Event",
|
||||
@@ -155,7 +175,7 @@
|
||||
"guidedSetup": "Guided Setup"
|
||||
},
|
||||
"welcome": {
|
||||
"fallbackName": "Tenant-Admin",
|
||||
"fallbackName": "Kunden-Admin",
|
||||
"greeting": "Hallo {{name}}!",
|
||||
"subtitle": "Behalte deine Events, Pakete und Aufgaben im Blick."
|
||||
},
|
||||
@@ -174,7 +194,7 @@
|
||||
},
|
||||
"overview": {
|
||||
"title": "Kurzer Überblick",
|
||||
"description": "Wichtigste Kennzahlen deines Tenants auf einen Blick.",
|
||||
"description": "Wichtigste Kennzahlen deines Nutzerkontos auf einen Blick.",
|
||||
"noPackage": "Kein aktives Paket",
|
||||
"stats": {
|
||||
"activePackage": "Aktives Paket",
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
},
|
||||
"transactions": {
|
||||
"title": "Paddle-Transaktionen",
|
||||
"description": "Neueste Paddle-Transaktionen für diesen Tenant.",
|
||||
"description": "Neueste Paddle-Transaktionen für dieses Kundenkonto.",
|
||||
"empty": "Noch keine Paddle-Transaktionen.",
|
||||
"labels": {
|
||||
"transactionId": "Transaktion {{id}}",
|
||||
@@ -121,7 +121,7 @@
|
||||
"empty": "Noch keine Events - starte jetzt und lege dein erstes Event an.",
|
||||
"count": "{{count}} {{count, plural, one {Event} other {Events}}} aktiv verwaltet.",
|
||||
"badge": {
|
||||
"dashboard": "Tenant Dashboard"
|
||||
"dashboard": "Kunden-Dashboard"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -168,7 +168,7 @@
|
||||
"submit": "Einladung senden"
|
||||
},
|
||||
"roles": {
|
||||
"tenantAdmin": "Tenant-Admin",
|
||||
"tenantAdmin": "Kunden-Admin",
|
||||
"member": "Mitglied",
|
||||
"guest": "Gast"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"layout": {
|
||||
"eyebrow": "Fotospiel Tenant Admin",
|
||||
"eyebrow": "Fotospiel Kunden-Admin",
|
||||
"title": "Willkommen im Event-Erlebnisstudio",
|
||||
"subtitle": "Starte mit einer inspirierten Einführung, sichere dir dein Event-Paket und kreiere die perfekte Gästegalerie – alles optimiert für mobile Hosts.",
|
||||
"alreadyFamiliar": "Schon vertraut mit Fotospiel?",
|
||||
@@ -180,7 +180,7 @@
|
||||
"pendingDescription": "Du kannst das Event bereits vorbereiten. Spätestens zur Veröffentlichung benötigst du ein aktives Paket."
|
||||
},
|
||||
"free": {
|
||||
"description": "Dieses Paket ist kostenlos. Du kannst es sofort deinem Tenant zuweisen und direkt mit dem Setup weitermachen.",
|
||||
"description": "Dieses Paket ist kostenlos. Du kannst es sofort deinem Kundenkonto zuweisen und direkt mit dem Setup weitermachen.",
|
||||
"activate": "Gratis-Paket aktivieren",
|
||||
"progress": "Aktivierung läuft …",
|
||||
"successTitle": "Gratis-Paket aktiviert",
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"panel_title": "Sign in",
|
||||
"panel_copy": "Sign in with your Fotospiel admin access. Sanctum personal access tokens and clear role permissions keep your account protected.",
|
||||
"actions_title": "Choose your sign-in method",
|
||||
"actions_copy": "Access the tenant dashboard securely with your Fotospiel login or your Google account.",
|
||||
"actions_copy": "Access the customer dashboard securely with your Fotospiel login or your Google account.",
|
||||
"cta": "Continue with Fotospiel login",
|
||||
"google_cta": "Continue with Google",
|
||||
"open_account_login": "Open account login",
|
||||
@@ -24,12 +24,12 @@
|
||||
"oauth_errors": {
|
||||
"login_required": "Please sign in to your Fotospiel account before continuing.",
|
||||
"invalid_request": "The login request was invalid. Please try again.",
|
||||
"invalid_client": "We couldn’t find the linked tenant app. Please contact support if this persists.",
|
||||
"invalid_client": "We couldn’t find the linked customer app. Please contact support if this persists.",
|
||||
"invalid_redirect": "The redirect address is not registered for this app.",
|
||||
"invalid_scope": "The app asked for permissions it cannot receive.",
|
||||
"tenant_mismatch": "You don’t have access to the tenant that requested this login.",
|
||||
"tenant_mismatch": "You don’t have access to the customer account that requested this login.",
|
||||
"google_failed": "Google sign-in was not successful. Please try again or use another method.",
|
||||
"google_no_match": "We couldn’t link this Google account to a tenant admin. Please sign in with Fotospiel credentials."
|
||||
"google_no_match": "We couldn’t link this Google account to a customer admin. Please sign in with Fotospiel credentials."
|
||||
},
|
||||
"return_hint": "After signing in you’ll be brought back automatically.",
|
||||
"support": "Need access? Contact your event team or email support@fotospiel.de — we're happy to help.",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"app": {
|
||||
"brand": "Fotospiel Tenant Admin",
|
||||
"brand": "Fotospiel Customer Admin",
|
||||
"languageSwitch": "Language",
|
||||
"userMenu": "Account",
|
||||
"help": "FAQ & Help",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"guidedSetup": "Guided setup"
|
||||
},
|
||||
"welcome": {
|
||||
"fallbackName": "Tenant Admin",
|
||||
"fallbackName": "Customer Admin",
|
||||
"greeting": "Welcome, {{name}}!",
|
||||
"subtitle": "Keep your events, packages, and tasks on track."
|
||||
},
|
||||
@@ -24,7 +24,7 @@
|
||||
},
|
||||
"overview": {
|
||||
"title": "At a glance",
|
||||
"description": "Key tenant metrics at a glance.",
|
||||
"description": "Key customer metrics at a glance.",
|
||||
"noPackage": "No active package",
|
||||
"stats": {
|
||||
"activePackage": "Active package",
|
||||
@@ -125,7 +125,7 @@
|
||||
},
|
||||
"faq": {
|
||||
"title": "FAQ & Help",
|
||||
"subtitle": "Answers and hints around the tenant admin.",
|
||||
"subtitle": "Answers and hints around the customer admin.",
|
||||
"intro": {
|
||||
"title": "What to expect",
|
||||
"description": "We are collecting feedback and will expand this help center step by step."
|
||||
@@ -148,6 +148,26 @@
|
||||
"contact": "Contact support"
|
||||
}
|
||||
},
|
||||
"helpCenter": {
|
||||
"title": "Help & documentation",
|
||||
"subtitle": "Structured guides and troubleshooting for customer admins.",
|
||||
"search": {
|
||||
"placeholder": "Search by topic or keyword"
|
||||
},
|
||||
"list": {
|
||||
"empty": "No articles found.",
|
||||
"error": "Help could not be loaded.",
|
||||
"retry": "Try again",
|
||||
"updated": "Updated {{date}}"
|
||||
},
|
||||
"article": {
|
||||
"placeholder": "Select an article on the left to view details.",
|
||||
"loading": "Loading article...",
|
||||
"error": "The article could not be loaded.",
|
||||
"updated": "Updated on {{date}}",
|
||||
"related": "Related articles"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"actions": {
|
||||
"newEvent": "New Event",
|
||||
@@ -155,7 +175,7 @@
|
||||
"guidedSetup": "Guided setup"
|
||||
},
|
||||
"welcome": {
|
||||
"fallbackName": "Tenant Admin",
|
||||
"fallbackName": "Customer Admin",
|
||||
"greeting": "Welcome, {{name}}!",
|
||||
"subtitle": "Keep your events, packages, and tasks on track."
|
||||
},
|
||||
@@ -174,7 +194,7 @@
|
||||
},
|
||||
"overview": {
|
||||
"title": "At a glance",
|
||||
"description": "Key tenant metrics at a glance.",
|
||||
"description": "Key customer metrics at a glance.",
|
||||
"noPackage": "No active package",
|
||||
"stats": {
|
||||
"activePackage": "Active package",
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
},
|
||||
"transactions": {
|
||||
"title": "Paddle transactions",
|
||||
"description": "Recent Paddle transactions for this tenant.",
|
||||
"description": "Recent Paddle transactions for this customer account.",
|
||||
"empty": "No Paddle transactions yet.",
|
||||
"labels": {
|
||||
"transactionId": "Transaction {{id}}",
|
||||
@@ -121,7 +121,7 @@
|
||||
"empty": "No events yet – create your first one to get started.",
|
||||
"count": "{{count}} {{count, plural, one {event} other {events}}} managed.",
|
||||
"badge": {
|
||||
"dashboard": "Tenant dashboard"
|
||||
"dashboard": "Customer dashboard"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -168,7 +168,7 @@
|
||||
"submit": "Send invitation"
|
||||
},
|
||||
"roles": {
|
||||
"tenantAdmin": "Tenant admin",
|
||||
"tenantAdmin": "Customer admin",
|
||||
"member": "Member",
|
||||
"guest": "Guest"
|
||||
},
|
||||
@@ -627,11 +627,11 @@
|
||||
"eventType": "Event type",
|
||||
"allEventTypes": "All event types",
|
||||
"globalOnly": "Global templates",
|
||||
"tenantOnly": "Tenant collections"
|
||||
"tenantOnly": "Customer collections"
|
||||
},
|
||||
"scope": {
|
||||
"global": "Global template",
|
||||
"tenant": "Tenant-owned"
|
||||
"tenant": "Customer-owned"
|
||||
},
|
||||
"empty": {
|
||||
"title": "No collections yet",
|
||||
@@ -676,7 +676,7 @@
|
||||
},
|
||||
"scope": {
|
||||
"global": "Global",
|
||||
"tenant": "Tenant"
|
||||
"tenant": "Customer"
|
||||
},
|
||||
"labels": {
|
||||
"updated": "Updated: {{date}}",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"layout": {
|
||||
"eyebrow": "Fotospiel Tenant Admin",
|
||||
"eyebrow": "Fotospiel Customer Admin",
|
||||
"title": "Welcome to your event studio",
|
||||
"subtitle": "Begin with an inspired introduction, secure your package, and craft the perfect guest gallery – all optimised for mobile hosts.",
|
||||
"alreadyFamiliar": "Already familiar with Fotospiel?",
|
||||
@@ -50,7 +50,7 @@
|
||||
"landingProgress": {
|
||||
"eyebrow": "Onboarding tracker",
|
||||
"title": "Stay aligned with your marketing dashboard",
|
||||
"description": "Complete these quick wins so the marketing dashboard reflects your latest tenant progress.",
|
||||
"description": "Complete these quick wins so the marketing dashboard reflects your latest customer progress.",
|
||||
"status": {
|
||||
"complete": "Completed",
|
||||
"pending": "Pending"
|
||||
@@ -180,7 +180,7 @@
|
||||
"pendingDescription": "You can start preparing the event. An active package is required before going live."
|
||||
},
|
||||
"free": {
|
||||
"description": "This package is free. Assign it to your tenant and continue immediately.",
|
||||
"description": "This package is free. Assign it to your customer account and continue immediately.",
|
||||
"activate": "Activate free package",
|
||||
"progress": "Activating …",
|
||||
"successTitle": "Free package activated",
|
||||
|
||||
389
resources/js/admin/pages/EventPhotoboothPage.tsx
Normal file
389
resources/js/admin/pages/EventPhotoboothPage.tsx
Normal file
@@ -0,0 +1,389 @@
|
||||
import React from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AlertCircle, ArrowLeft, Loader2, PlugZap, Power, RefreshCw, ShieldCheck, Copy } from 'lucide-react';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
import { AdminLayout } from '../components/AdminLayout';
|
||||
import {
|
||||
PhotoboothStatus,
|
||||
TenantEvent,
|
||||
disableEventPhotobooth,
|
||||
enableEventPhotobooth,
|
||||
getEvent,
|
||||
getEventPhotoboothStatus,
|
||||
rotateEventPhotobooth,
|
||||
} from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import { getApiErrorMessage } from '../lib/apiError';
|
||||
import { ADMIN_EVENTS_PATH, ADMIN_EVENT_VIEW_PATH } from '../constants';
|
||||
|
||||
type State = {
|
||||
event: TenantEvent | null;
|
||||
status: PhotoboothStatus | null;
|
||||
loading: boolean;
|
||||
updating: boolean;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
export default function EventPhotoboothPage() {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation(['management', 'common']);
|
||||
|
||||
const [state, setState] = React.useState<State>({
|
||||
event: null,
|
||||
status: null,
|
||||
loading: true,
|
||||
updating: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
if (!slug) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
error: t('management.photobooth.errors.missingSlug', 'Kein Event ausgewählt.'),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
setState((prev) => ({ ...prev, loading: true, error: null }));
|
||||
|
||||
try {
|
||||
const [eventData, statusData] = await Promise.all([getEvent(slug), getEventPhotoboothStatus(slug)]);
|
||||
setState({
|
||||
event: eventData,
|
||||
status: statusData,
|
||||
loading: false,
|
||||
updating: false,
|
||||
error: null,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isAuthError(error)) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
error: getApiErrorMessage(error, t('management.photobooth.errors.loadFailed', 'Photobooth-Link konnte nicht geladen werden.')),
|
||||
}));
|
||||
} else {
|
||||
setState((prev) => ({ ...prev, loading: false }));
|
||||
}
|
||||
}
|
||||
}, [slug, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
async function handleEnable(): Promise<void> {
|
||||
if (!slug) return;
|
||||
setState((prev) => ({ ...prev, updating: true, error: null }));
|
||||
|
||||
try {
|
||||
const result = await enableEventPhotobooth(slug);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
status: result,
|
||||
updating: false,
|
||||
}));
|
||||
} catch (error) {
|
||||
if (!isAuthError(error)) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
updating: false,
|
||||
error: getApiErrorMessage(error, t('management.photobooth.errors.enableFailed', 'Zugang konnte nicht aktiviert werden.')),
|
||||
}));
|
||||
} else {
|
||||
setState((prev) => ({ ...prev, updating: false }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRotate(): Promise<void> {
|
||||
if (!slug) return;
|
||||
setState((prev) => ({ ...prev, updating: true, error: null }));
|
||||
|
||||
try {
|
||||
const result = await rotateEventPhotobooth(slug);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
status: result,
|
||||
updating: false,
|
||||
}));
|
||||
} catch (error) {
|
||||
if (!isAuthError(error)) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
updating: false,
|
||||
error: getApiErrorMessage(error, t('management.photobooth.errors.rotateFailed', 'Zugangsdaten konnten nicht neu generiert werden.')),
|
||||
}));
|
||||
} else {
|
||||
setState((prev) => ({ ...prev, updating: false }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDisable(): Promise<void> {
|
||||
if (!slug) return;
|
||||
if (!window.confirm(t('management.photobooth.confirm.disable', 'Photobooth-Zugang deaktivieren?'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState((prev) => ({ ...prev, updating: true, error: null }));
|
||||
|
||||
try {
|
||||
const result = await disableEventPhotobooth(slug);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
status: result,
|
||||
updating: false,
|
||||
}));
|
||||
} catch (error) {
|
||||
if (!isAuthError(error)) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
updating: false,
|
||||
error: getApiErrorMessage(error, t('management.photobooth.errors.disableFailed', 'Zugang konnte nicht deaktiviert werden.')),
|
||||
}));
|
||||
} else {
|
||||
setState((prev) => ({ ...prev, updating: false }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { event, status, loading, updating, error } = state;
|
||||
const title = event
|
||||
? t('management.photobooth.titleForEvent', { defaultValue: 'Fotobox-Uploads verwalten', event: resolveEventName(event) })
|
||||
: t('management.photobooth.title', 'Fotobox-Uploads');
|
||||
const subtitle = t(
|
||||
'management.photobooth.subtitle',
|
||||
'Erstelle einen einfachen FTP-Link für Photobooth-Software. Rate-Limit: 20 Fotos/Minute.'
|
||||
);
|
||||
|
||||
const actions = (
|
||||
<div className="flex gap-2">
|
||||
{slug ? (
|
||||
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_VIEW_PATH(slug))}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
{t('management.photobooth.actions.backToEvent', 'Zur Detailansicht')}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant="ghost" onClick={() => navigate(ADMIN_EVENTS_PATH)}>
|
||||
{t('management.photobooth.actions.allEvents', 'Zur Eventliste')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<AdminLayout title={title} subtitle={subtitle} actions={actions}>
|
||||
{error ? (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertTitle>{t('common:messages.error', 'Fehler')}</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<PhotoboothSkeleton />
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<StatusCard status={status} />
|
||||
<CredentialsCard status={status} updating={updating} onEnable={handleEnable} onRotate={handleRotate} onDisable={handleDisable} />
|
||||
<RateLimitCard status={status} />
|
||||
</div>
|
||||
)}
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function resolveEventName(name: TenantEvent['name']): string {
|
||||
if (typeof name === 'string') {
|
||||
return name;
|
||||
}
|
||||
if (name && typeof name === 'object') {
|
||||
return Object.values(name)[0] ?? 'Event';
|
||||
}
|
||||
return 'Event';
|
||||
}
|
||||
|
||||
function PhotoboothSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 3 }).map((_, idx) => (
|
||||
<div key={idx} className="rounded-3xl border border-slate-200/80 bg-white/70 p-6 shadow-sm">
|
||||
<div className="h-4 w-32 animate-pulse rounded bg-slate-200/80" />
|
||||
<div className="mt-4 h-3 w-full animate-pulse rounded bg-slate-100" />
|
||||
<div className="mt-2 h-3 w-3/4 animate-pulse rounded bg-slate-100" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusCard({ status }: { status: PhotoboothStatus | null }) {
|
||||
const { t } = useTranslation('management');
|
||||
const isActive = Boolean(status?.enabled);
|
||||
const badgeColor = isActive ? 'bg-emerald-600 text-white' : 'bg-slate-300 text-slate-800';
|
||||
const icon = isActive ? <PlugZap className="h-5 w-5 text-emerald-500" /> : <Power className="h-5 w-5 text-slate-400" />;
|
||||
|
||||
return (
|
||||
<Card className="rounded-3xl border border-slate-200/80 shadow-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle>{t('photobooth.status.heading', 'Status')}</CardTitle>
|
||||
<CardDescription>
|
||||
{isActive
|
||||
? t('photobooth.status.active', 'Photobooth-Link ist aktiv.')
|
||||
: t('photobooth.status.inactive', 'Noch keine Photobooth-Uploads angebunden.')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{icon}
|
||||
<Badge className={badgeColor}>
|
||||
{isActive ? t('photobooth.status.badgeActive', 'AKTIV') : t('photobooth.status.badgeInactive', 'INAKTIV')}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{status?.expires_at ? (
|
||||
<CardContent className="text-sm text-slate-600">
|
||||
{t('photobooth.status.expiresAt', 'Automatisches Abschalten am {{date}}', {
|
||||
date: new Date(status.expires_at).toLocaleString(),
|
||||
})}
|
||||
</CardContent>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
type CredentialCardProps = {
|
||||
status: PhotoboothStatus | null;
|
||||
updating: boolean;
|
||||
onEnable: () => Promise<void>;
|
||||
onRotate: () => Promise<void>;
|
||||
onDisable: () => Promise<void>;
|
||||
};
|
||||
|
||||
function CredentialsCard({ status, updating, onEnable, onRotate, onDisable }: CredentialCardProps) {
|
||||
const { t } = useTranslation('management');
|
||||
const isActive = Boolean(status?.enabled);
|
||||
|
||||
return (
|
||||
<Card className="rounded-3xl border border-rose-100/80 shadow-lg shadow-rose-100/40">
|
||||
<CardHeader>
|
||||
<CardTitle>{t('photobooth.credentials.heading', 'FTP-Zugangsdaten')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
'photobooth.credentials.description',
|
||||
'Teile die Zugangsdaten mit der Photobooth-Software. Passwörter werden max. 8 Zeichen lang generiert.'
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field label={t('photobooth.credentials.host', 'Host')} value={status?.ftp.host ?? '—'} />
|
||||
<Field label={t('photobooth.credentials.port', 'Port')} value={String(status?.ftp.port ?? 2121)} />
|
||||
<Field label={t('photobooth.credentials.username', 'Benutzername')} value={status?.username ?? '—'} copyable />
|
||||
<Field label={t('photobooth.credentials.password', 'Passwort')} value={status?.password ?? '—'} copyable sensitive />
|
||||
<Field label={t('photobooth.credentials.path', 'Upload-Pfad')} value={status?.path ?? '/photobooth/...'} copyable />
|
||||
<Field label="FTP-Link" value={status?.ftp_url ?? '—'} copyable className="md:col-span-2" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{isActive ? (
|
||||
<>
|
||||
<Button onClick={onRotate} disabled={updating} className="bg-rose-600 text-white hover:bg-rose-500">
|
||||
{updating ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <RefreshCw className="mr-2 h-4 w-4" />}
|
||||
{t('photobooth.actions.rotate', 'Zugang neu generieren')}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={onDisable} disabled={updating}>
|
||||
<Power className="mr-2 h-4 w-4" />
|
||||
{t('photobooth.actions.disable', 'Deaktivieren')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button onClick={onEnable} disabled={updating} className="bg-rose-600 text-white hover:bg-rose-500">
|
||||
{updating ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <PlugZap className="mr-2 h-4 w-4" />}
|
||||
{t('photobooth.actions.enable', 'Photobooth aktivieren')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function RateLimitCard({ status }: { status: PhotoboothStatus | null }) {
|
||||
const { t } = useTranslation('management');
|
||||
const rateLimit = status?.rate_limit_per_minute ?? 20;
|
||||
|
||||
return (
|
||||
<Card className="rounded-3xl border border-slate-200/80 bg-white shadow-sm">
|
||||
<CardHeader className="flex flex-row items-center gap-3">
|
||||
<ShieldCheck className="h-5 w-5 text-emerald-500" />
|
||||
<div>
|
||||
<CardTitle>{t('photobooth.rateLimit.heading', 'Sicherheit & Limits')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('photobooth.rateLimit.description', 'Uploads werden strikt auf {{count}} Fotos pro Minute begrenzt.', {
|
||||
count: rateLimit,
|
||||
})}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm leading-relaxed text-slate-600">
|
||||
<p>
|
||||
{t(
|
||||
'photobooth.rateLimit.body',
|
||||
'Bei Überschreitung wird die Verbindung hart geblockt. Nach 60 Sekunden wird der Zugang automatisch wieder freigegeben.'
|
||||
)}
|
||||
</p>
|
||||
<p className="mt-3 text-xs text-slate-500">
|
||||
<AlertCircle className="mr-1 inline h-3.5 w-3.5" />
|
||||
{t(
|
||||
'photobooth.rateLimit.hint',
|
||||
'Ablaufzeit stimmt mit dem Event-Ende überein. Nach Ablauf wird der Account automatisch entfernt.'
|
||||
)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
type FieldProps = {
|
||||
label: string;
|
||||
value: string;
|
||||
copyable?: boolean;
|
||||
sensitive?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function Field({ label, value, copyable, sensitive, className }: FieldProps) {
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
const showValue = sensitive && value && value !== '—' ? '•'.repeat(Math.min(6, value.length)) : value;
|
||||
|
||||
async function handleCopy() {
|
||||
if (!copyable || !value || value === '—') return;
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`rounded-2xl border border-slate-200/80 bg-white/70 p-4 shadow-inner ${className ?? ''}`}>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">{label}</p>
|
||||
<div className="mt-1 flex items-center justify-between gap-2">
|
||||
<span className="truncate text-base font-medium text-slate-900">{showValue}</span>
|
||||
{copyable ? (
|
||||
<Button variant="ghost" size="icon" onClick={handleCopy} aria-label="Copy" disabled={!value || value === '—'}>
|
||||
{copied ? <ShieldCheck className="h-4 w-4 text-emerald-500" /> : <Copy className="h-4 w-4 text-slate-500" />}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,80 +1,245 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Loader2, RefreshCcw } from 'lucide-react';
|
||||
|
||||
import { AdminLayout } from '../components/AdminLayout';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { HelpCenterArticleSummary, HelpCenterArticle } from '../api';
|
||||
import { fetchHelpCenterArticles, fetchHelpCenterArticle } from '../api';
|
||||
|
||||
function normalizeLocale(language: string | undefined): 'de' | 'en' {
|
||||
const normalized = (language ?? 'de').toLowerCase().split('-')[0];
|
||||
return normalized === 'en' ? 'en' : 'de';
|
||||
}
|
||||
|
||||
export default function FaqPage() {
|
||||
const { t } = useTranslation('dashboard');
|
||||
const { t, i18n } = useTranslation('dashboard');
|
||||
const helpLocale = normalizeLocale(i18n.language);
|
||||
const [query, setQuery] = React.useState('');
|
||||
const [articles, setArticles] = React.useState<HelpCenterArticleSummary[]>([]);
|
||||
const [listState, setListState] = React.useState<'loading' | 'ready' | 'error'>('loading');
|
||||
const [selectedSlug, setSelectedSlug] = React.useState<string | null>(null);
|
||||
const [detailState, setDetailState] = React.useState<'idle' | 'loading' | 'ready' | 'error'>('idle');
|
||||
const [articleCache, setArticleCache] = React.useState<Record<string, HelpCenterArticle>>({});
|
||||
|
||||
const entries = [
|
||||
{
|
||||
question: t('faq.events.question', 'Wie arbeite ich mit Events?'),
|
||||
answer: t(
|
||||
'faq.events.answer',
|
||||
'Wähle dein aktives Event, passe Aufgaben an und lade Gäste über die Einladungsseite ein. Weitere Dokumentation folgt bald.'
|
||||
),
|
||||
},
|
||||
{
|
||||
question: t('faq.uploads.question', 'Wie moderiere ich Uploads?'),
|
||||
answer: t(
|
||||
'faq.uploads.answer',
|
||||
'Sobald Fotos eintreffen, findest du sie in der Galerie-Ansicht deines Events. Von dort kannst du sie freigeben oder zurückweisen.'
|
||||
),
|
||||
},
|
||||
{
|
||||
question: t('faq.support.question', 'Wo erhalte ich Support?'),
|
||||
answer: t(
|
||||
'faq.support.answer',
|
||||
'Dieses FAQ dient als Platzhalter. Bitte nutze vorerst den bekannten Support-Kanal, bis die Wissensdatenbank veröffentlicht wird.'
|
||||
),
|
||||
},
|
||||
];
|
||||
const loadArticles = React.useCallback(async () => {
|
||||
setListState('loading');
|
||||
try {
|
||||
const data = await fetchHelpCenterArticles(helpLocale);
|
||||
setArticles(data);
|
||||
setListState('ready');
|
||||
} catch (error) {
|
||||
console.error('[HelpCenter] Failed to load list', error);
|
||||
setListState('error');
|
||||
}
|
||||
}, [helpLocale]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setArticles([]);
|
||||
setArticleCache({});
|
||||
setSelectedSlug(null);
|
||||
loadArticles();
|
||||
}, [loadArticles]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!selectedSlug && articles.length > 0) {
|
||||
setSelectedSlug(articles[0].slug);
|
||||
} else if (selectedSlug && !articles.some((article) => article.slug === selectedSlug)) {
|
||||
setSelectedSlug(articles[0]?.slug ?? null);
|
||||
}
|
||||
}, [articles, selectedSlug]);
|
||||
|
||||
const loadArticle = React.useCallback(async (slug: string, options?: { bypassCache?: boolean }) => {
|
||||
if (!slug) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bypassCache = options?.bypassCache ?? false;
|
||||
if (!bypassCache && articleCache[slug]) {
|
||||
setDetailState('ready');
|
||||
return;
|
||||
}
|
||||
|
||||
setDetailState('loading');
|
||||
try {
|
||||
const article = await fetchHelpCenterArticle(slug, helpLocale);
|
||||
setArticleCache((prev) => ({ ...prev, [slug]: article }));
|
||||
setDetailState('ready');
|
||||
} catch (error) {
|
||||
console.error('[HelpCenter] Failed to load article', error);
|
||||
setDetailState('error');
|
||||
}
|
||||
}, [articleCache, helpLocale]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (selectedSlug) {
|
||||
loadArticle(selectedSlug);
|
||||
}
|
||||
}, [selectedSlug, loadArticle]);
|
||||
|
||||
const filteredArticles = React.useMemo(() => {
|
||||
if (!query.trim()) {
|
||||
return articles;
|
||||
}
|
||||
const needle = query.trim().toLowerCase();
|
||||
return articles.filter((article) => `${article.title} ${article.summary}`.toLowerCase().includes(needle));
|
||||
}, [articles, query]);
|
||||
|
||||
const activeArticle = selectedSlug ? articleCache[selectedSlug] : null;
|
||||
|
||||
return (
|
||||
<AdminLayout
|
||||
title={t('faq.title', 'FAQ & Hilfe')}
|
||||
subtitle={t('faq.subtitle', 'Antworten und Hinweise rund um den Tenant Admin.')}
|
||||
title={t('helpCenter.title', 'Hilfe & Dokumentation')}
|
||||
subtitle={t('helpCenter.subtitle', 'Aktuelle Playbooks für dein Team.')}
|
||||
>
|
||||
<div className="grid gap-6 lg:grid-cols-[320px,1fr]">
|
||||
<Card className="border-slate-200 bg-white/90 shadow-sm dark:border-white/10 dark:bg-white/5">
|
||||
<CardHeader>
|
||||
<CardTitle>{t('faq.intro.title', 'Was dich erwartet')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
'faq.intro.description',
|
||||
'Wir sammeln aktuell Feedback und erweitern dieses Hilfe-Center Schritt für Schritt.'
|
||||
)}
|
||||
</CardDescription>
|
||||
<CardTitle>{t('helpCenter.title', 'Hilfe & Dokumentation')}</CardTitle>
|
||||
<CardDescription>{t('helpCenter.subtitle', 'Aktuelle Playbooks für dein Team.')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{entries.map((entry) => (
|
||||
<div key={entry.question} className="rounded-2xl border border-slate-200/80 p-4 dark:border-white/10">
|
||||
<p className="text-sm font-semibold text-slate-900 dark:text-white">{entry.question}</p>
|
||||
<p className="mt-2 text-sm text-slate-600 dark:text-slate-300">{entry.answer}</p>
|
||||
<CardContent className="space-y-4">
|
||||
<Input
|
||||
placeholder={t('helpCenter.search.placeholder', 'Suche nach Thema')}
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
<div>
|
||||
{listState === 'loading' && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t('helpCenter.article.loading', 'Lädt...')}
|
||||
</div>
|
||||
))}
|
||||
<div className="rounded-2xl bg-rose-50/70 p-4 text-sm text-rose-900 dark:bg-rose-200/10 dark:text-rose-100">
|
||||
<p className="font-semibold">
|
||||
{t('faq.cta.needHelp', 'Fehlt dir etwas?')}
|
||||
</p>
|
||||
<p className="mt-1 text-sm">
|
||||
{t(
|
||||
'faq.cta.description',
|
||||
'Schreib uns dein Feedback direkt aus dem Admin oder per Support-Mail – wir erweitern dieses FAQ mit deinen Themen.'
|
||||
)}
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="mt-3 rounded-full"
|
||||
onClick={() => window.open('mailto:hello@fotospiel.app', '_blank')}
|
||||
>
|
||||
{t('faq.cta.contact', 'Support kontaktieren')}
|
||||
{listState === 'error' && (
|
||||
<div className="space-y-3 rounded-lg border border-rose-100 bg-rose-50/70 p-3 text-sm text-rose-900 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-100">
|
||||
<p>{t('helpCenter.list.error')}</p>
|
||||
<Button variant="secondary" size="sm" onClick={loadArticles}>
|
||||
<span className="flex items-center gap-2">
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
{t('helpCenter.list.retry')}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{listState === 'ready' && filteredArticles.length === 0 && (
|
||||
<div className="rounded-lg border border-dashed border-slate-200/80 p-4 text-sm text-muted-foreground dark:border-white/10">
|
||||
{t('helpCenter.list.empty')}
|
||||
</div>
|
||||
)}
|
||||
{listState === 'ready' && filteredArticles.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{filteredArticles.map((article) => {
|
||||
const isActive = selectedSlug === article.slug;
|
||||
return (
|
||||
<button
|
||||
key={article.slug}
|
||||
type="button"
|
||||
onClick={() => setSelectedSlug(article.slug)}
|
||||
className={`w-full rounded-xl border p-3 text-left transition-colors ${
|
||||
isActive
|
||||
? 'border-sky-500 bg-sky-500/5 shadow-sm'
|
||||
: 'border-slate-200/80 hover:border-sky-400/70'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-semibold text-slate-900 dark:text-white">{article.title}</p>
|
||||
<p className="mt-1 text-sm text-slate-500 dark:text-slate-300 line-clamp-2">{article.summary}</p>
|
||||
</div>
|
||||
{article.updated_at && (
|
||||
<span className="text-xs text-slate-400 dark:text-slate-500">
|
||||
{t('helpCenter.list.updated', { date: formatDate(article.updated_at, i18n.language) })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-slate-200 bg-white/95 shadow-sm dark:border-white/10 dark:bg-white/5">
|
||||
<CardHeader>
|
||||
<CardTitle>{activeArticle?.title ?? t('helpCenter.article.placeholder')}</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-500 dark:text-slate-300">
|
||||
{activeArticle?.summary ?? ''}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{!selectedSlug && (
|
||||
<p className="text-sm text-muted-foreground">{t('helpCenter.article.placeholder')}</p>
|
||||
)}
|
||||
|
||||
{selectedSlug && detailState === 'loading' && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t('helpCenter.article.loading')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedSlug && detailState === 'error' && (
|
||||
<div className="space-y-3 rounded-lg border border-rose-100 bg-rose-50/70 p-4 text-sm text-rose-900 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-100">
|
||||
<p>{t('helpCenter.article.error')}</p>
|
||||
<Button size="sm" variant="secondary" onClick={() => loadArticle(selectedSlug, { bypassCache: true })}>
|
||||
{t('helpCenter.list.retry')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detailState === 'ready' && activeArticle && (
|
||||
<div className="space-y-6">
|
||||
{activeArticle.updated_at && (
|
||||
<Badge variant="outline" className="border-slate-200 text-xs font-normal text-slate-600 dark:border-white/20 dark:text-slate-300">
|
||||
{t('helpCenter.article.updated', { date: formatDate(activeArticle.updated_at, i18n.language) })}
|
||||
</Badge>
|
||||
)}
|
||||
<div
|
||||
className="prose prose-sm max-w-none text-slate-700 dark:prose-invert"
|
||||
dangerouslySetInnerHTML={{ __html: activeArticle.body_html ?? activeArticle.body_markdown ?? '' }}
|
||||
/>
|
||||
{activeArticle.related && activeArticle.related.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold text-slate-900 dark:text-white">
|
||||
{t('helpCenter.article.related')}
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{activeArticle.related.map((rel) => (
|
||||
<Button
|
||||
key={rel.slug}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setSelectedSlug(rel.slug)}
|
||||
>
|
||||
{rel.slug}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(value: string, language: string | undefined): string {
|
||||
try {
|
||||
return new Date(value).toLocaleDateString(language ?? 'de-DE', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
});
|
||||
} catch (error) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ export default function SettingsPage() {
|
||||
t('settings.hero.summary.appearance', { defaultValue: 'Synchronisiere den Look & Feel mit dem Gästeportal oder schalte den Dark Mode frei.' }),
|
||||
t('settings.hero.summary.notifications', { defaultValue: 'Stimme Benachrichtigungen auf Aufgaben, Pakete und Live-Events ab.' })
|
||||
];
|
||||
const accountName = user?.name ?? user?.email ?? 'Tenant Admin';
|
||||
const accountName = user?.name ?? user?.email ?? 'Customer Admin';
|
||||
const heroPrimaryAction = (
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -140,7 +140,7 @@ export default function SettingsPage() {
|
||||
<p className="text-sm text-slate-600">
|
||||
{user ? (
|
||||
<>
|
||||
Eingeloggt als <span className="font-medium text-slate-900">{user.name ?? user.email ?? 'Tenant Admin'}</span>
|
||||
Eingeloggt als <span className="font-medium text-slate-900">{user.name ?? user.email ?? 'Customer Admin'}</span>
|
||||
{user.tenant_id && <> - Tenant #{user.tenant_id}</>}
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -21,6 +21,7 @@ const EventMembersPage = React.lazy(() => import('./pages/EventMembersPage'));
|
||||
const EventTasksPage = React.lazy(() => import('./pages/EventTasksPage'));
|
||||
const EventToolkitPage = React.lazy(() => import('./pages/EventToolkitPage'));
|
||||
const EventInvitesPage = React.lazy(() => import('./pages/EventInvitesPage'));
|
||||
const EventPhotoboothPage = React.lazy(() => import('./pages/EventPhotoboothPage'));
|
||||
const EngagementPage = React.lazy(() => import('./pages/EngagementPage'));
|
||||
const BillingPage = React.lazy(() => import('./pages/BillingPage'));
|
||||
const TasksPage = React.lazy(() => import('./pages/TasksPage'));
|
||||
@@ -107,6 +108,7 @@ export const router = createBrowserRouter([
|
||||
{ path: 'events/:slug/members', element: <RequireAdminAccess><EventMembersPage /></RequireAdminAccess> },
|
||||
{ path: 'events/:slug/tasks', element: <EventTasksPage /> },
|
||||
{ path: 'events/:slug/invites', element: <EventInvitesPage /> },
|
||||
{ path: 'events/:slug/photobooth', element: <RequireAdminAccess><EventPhotoboothPage /></RequireAdminAccess> },
|
||||
{ path: 'events/:slug/toolkit', element: <EventToolkitPage /> },
|
||||
{ path: 'engagement', element: <EngagementPage /> },
|
||||
{ path: 'tasks', element: <TasksPage /> },
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user