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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@@ -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),

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

View File

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

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

View File

@@ -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,

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

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

View File

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

View File

@@ -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(

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

View File

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

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

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

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

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

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

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

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

View File

@@ -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
View 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) ?? [],
];

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -19,11 +19,12 @@ class PhotoFactory extends Factory
'emotion_id' => Emotion::factory(),
'task_id' => null,
'guest_name' => $this->faker->name(),
'file_path' => 'photos/' . Str::uuid() . '.jpg',
'thumbnail_path' => 'photos/thumbnails/' . Str::uuid() . '.jpg',
'file_path' => 'photos/'.Str::uuid().'.jpg',
'thumbnail_path' => 'photos/thumbnails/'.Str::uuid().'.jpg',
'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
});
}
}

View File

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

View File

@@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::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',
]);
});
}
};

View File

@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::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');
}
});
}
};

View File

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

View File

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

View 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 Coolifys “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 Coolifys “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 Coolifys 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 Coolifys 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 Laravels built-in scheduler and worker scripts. The next step is implementing the `CoolifyClient` + Filament widgets described above.

View File

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

View 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 >10Min 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.

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

View 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
---
## 4824 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).
## 242 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.

View 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
---
## 4824 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).
## 242 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.

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

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

View 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 15Sekunden 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 10Minuten.
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.

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

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

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

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

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

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

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

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

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

View 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 (Top10 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 500MB 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.

View 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 500MB; 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; theres 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.

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

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

View 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. 30Sekunden (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.

View 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 dont block photo uploads.
### Need more help?
Read `offline-sync` for long offline stretches or contact support from Settings → Help.

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

View File

@@ -0,0 +1,30 @@
---
title: "<Title>"
locale: en
slug: <slug>
audience: guest
summary: "12 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.

View File

@@ -0,0 +1,102 @@
# Photobooth FTP Ingestion
This guide explains how to operate the Photobooth FTP workflow endtoend: 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 events 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.

View 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 5s).
## 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 users 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 Laravels 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.

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

View File

@@ -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 Coolifys “One-off command” feature to run migrations or `queue:retry`.
- Expose the Horizon service through Coolifys 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 Coolifys dashboard; the upcoming SuperAdmin integration will surface the same metrics/actions through a dedicated Filament widget.

View File

@@ -1,5 +1,5 @@
{
"name": "Fotospiel Tenant Admin",
"name": "Fotospiel Customer Admin",
"short_name": "Fotospiel Admin",
"id": "/event-admin",
"start_url": "/event-admin/",

View File

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

View File

@@ -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`);

View File

@@ -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.",

View File

@@ -1,6 +1,6 @@
{
"app": {
"brand": "Fotospiel Tenant Admin",
"brand": "Fotospiel Kunden-Admin",
"languageSwitch": "Sprache",
"userMenu": "Konto",
"help": "FAQ & Hilfe",

View File

@@ -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",

View File

@@ -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"
},

View File

@@ -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",

View File

@@ -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 couldnt find the linked tenant app. Please contact support if this persists.",
"invalid_client": "We couldnt 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 dont have access to the tenant that requested this login.",
"tenant_mismatch": "You dont 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 couldnt link this Google account to a tenant admin. Please sign in with Fotospiel credentials."
"google_no_match": "We couldnt link this Google account to a customer admin. Please sign in with Fotospiel credentials."
},
"return_hint": "After signing in youll be brought back automatically.",
"support": "Need access? Contact your event team or email support@fotospiel.de — we're happy to help.",

View File

@@ -1,6 +1,6 @@
{
"app": {
"brand": "Fotospiel Tenant Admin",
"brand": "Fotospiel Customer Admin",
"languageSwitch": "Language",
"userMenu": "Account",
"help": "FAQ & Help",

View File

@@ -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",

View File

@@ -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}}",

View File

@@ -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",

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

View File

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

View File

@@ -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}</>}
</>
) : (

View File

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