feat: extend event toolkit and polish guest pwa

This commit is contained in:
Codex Agent
2025-10-28 18:28:22 +01:00
parent f29067f570
commit a7bbf230fd
45 changed files with 3809 additions and 351 deletions

View File

@@ -4,7 +4,9 @@ namespace App\Http\Controllers\Api\Tenant;
use App\Http\Controllers\Controller;
use App\Http\Requests\Tenant\EventStoreRequest;
use App\Http\Resources\Tenant\EventJoinTokenResource;
use App\Http\Resources\Tenant\EventResource;
use App\Http\Resources\Tenant\PhotoResource;
use App\Models\Event;
use App\Models\EventPackage;
use App\Models\Package;
@@ -228,6 +230,10 @@ class EventController extends Controller
unset($validated[$unused]);
}
if (isset($validated['settings']) && is_array($validated['settings'])) {
$validated['settings'] = array_merge($event->settings ?? [], $validated['settings']);
}
$event->update($validated);
$event->load(['eventType', 'tenant']);
@@ -277,6 +283,141 @@ class EventController extends Controller
]);
}
public function toolkit(Request $request, Event $event): JsonResponse
{
$tenantId = $request->attributes->get('tenant_id');
if ($event->tenant_id !== $tenantId) {
return response()->json(['error' => 'Event not found'], 404);
}
$event->load(['eventType', 'eventPackage.package']);
$photoQuery = Photo::query()->where('event_id', $event->id);
$pendingPhotos = (clone $photoQuery)
->where('status', 'pending')
->latest('created_at')
->take(6)
->get();
$recentUploads = (clone $photoQuery)
->where('status', 'approved')
->latest('created_at')
->take(8)
->get();
$pendingCount = (clone $photoQuery)->where('status', 'pending')->count();
$uploads24h = (clone $photoQuery)->where('created_at', '>=', now()->subDay())->count();
$totalUploads = (clone $photoQuery)->count();
$tasks = $event->tasks()
->orderBy('tasks.sort_order')
->orderBy('tasks.created_at')
->get(['tasks.id', 'tasks.title', 'tasks.description', 'tasks.priority', 'tasks.is_completed']);
$taskSummary = [
'total' => $tasks->count(),
'completed' => $tasks->where('is_completed', true)->count(),
];
$taskSummary['pending'] = max(0, $taskSummary['total'] - $taskSummary['completed']);
$translate = static function ($value, string $fallback = '') {
if (is_array($value)) {
$locale = app()->getLocale();
$candidates = array_filter([
$locale,
$locale && str_contains($locale, '-') ? explode('-', $locale)[0] : null,
'de',
'en',
]);
foreach ($candidates as $candidate) {
if ($candidate && isset($value[$candidate]) && $value[$candidate] !== '') {
return $value[$candidate];
}
}
$first = reset($value);
return $first !== false ? $first : $fallback;
}
if (is_string($value) && $value !== '') {
return $value;
}
return $fallback;
};
$taskPreview = $tasks
->take(6)
->map(fn ($task) => [
'id' => $task->id,
'title' => $translate($task->title, 'Task'),
'description' => $translate($task->description, null),
'is_completed' => (bool) $task->is_completed,
'priority' => $task->priority,
])
->values();
$joinTokenQuery = $event->joinTokens();
$totalInvites = (clone $joinTokenQuery)->count();
$activeInvites = (clone $joinTokenQuery)
->whereNull('revoked_at')
->where(function ($query) {
$query->whereNull('expires_at')
->orWhere('expires_at', '>', now());
})
->where(function ($query) {
$query->whereNull('usage_limit')
->orWhereColumn('usage_limit', '>', 'usage_count');
})
->count();
$recentInvites = (clone $joinTokenQuery)
->orderByDesc('created_at')
->take(3)
->get();
$alerts = [];
if (($event->settings['engagement_mode'] ?? 'tasks') !== 'photo_only' && $taskSummary['total'] === 0) {
$alerts[] = 'no_tasks';
}
if ($activeInvites === 0) {
$alerts[] = 'no_invites';
}
if ($pendingCount > 0) {
$alerts[] = 'pending_photos';
}
return response()->json([
'event' => new EventResource($event),
'metrics' => [
'uploads_total' => $totalUploads,
'uploads_24h' => $uploads24h,
'pending_photos' => $pendingCount,
'active_invites' => $activeInvites,
'engagement_mode' => $event->settings['engagement_mode'] ?? 'tasks',
],
'tasks' => [
'summary' => $taskSummary,
'items' => $taskPreview,
],
'photos' => [
'pending' => PhotoResource::collection($pendingPhotos)->resolve($request),
'recent' => PhotoResource::collection($recentUploads)->resolve($request),
],
'invites' => [
'summary' => [
'total' => $totalInvites,
'active' => $activeInvites,
],
'items' => EventJoinTokenResource::collection($recentInvites)->resolve($request),
],
'alerts' => $alerts,
]);
}
public function toggle(Request $request, Event $event): JsonResponse
{
$tenantId = $request->attributes->get('tenant_id');

View File

@@ -7,6 +7,7 @@ use App\Http\Resources\Tenant\EventJoinTokenResource;
use App\Models\Event;
use App\Models\EventJoinToken;
use App\Services\EventJoinTokenService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\Auth;
@@ -30,12 +31,7 @@ class EventJoinTokenController extends Controller
{
$this->authorizeEvent($request, $event);
$validated = $request->validate([
'label' => ['nullable', 'string', 'max:255'],
'expires_at' => ['nullable', 'date', 'after:now'],
'usage_limit' => ['nullable', 'integer', 'min:1'],
'metadata' => ['nullable', 'array'],
]);
$validated = $this->validatePayload($request);
$token = $this->joinTokenService->createToken($event, array_merge($validated, [
'created_by' => Auth::id(),
@@ -46,6 +42,50 @@ class EventJoinTokenController extends Controller
->setStatusCode(201);
}
public function update(Request $request, Event $event, EventJoinToken $joinToken): EventJoinTokenResource
{
$this->authorizeEvent($request, $event);
if ($joinToken->event_id !== $event->id) {
abort(404);
}
$validated = $this->validatePayload($request, true);
$payload = [];
if (array_key_exists('label', $validated)) {
$payload['label'] = $validated['label'];
}
if (array_key_exists('expires_at', $validated)) {
$payload['expires_at'] = $validated['expires_at'];
}
if (array_key_exists('usage_limit', $validated)) {
$payload['usage_limit'] = $validated['usage_limit'];
}
if (! empty($payload)) {
$joinToken->fill($payload);
}
if (array_key_exists('metadata', $validated)) {
$current = is_array($joinToken->metadata) ? $joinToken->metadata : [];
$incoming = $validated['metadata'];
if ($incoming === null) {
$joinToken->metadata = null;
} else {
$joinToken->metadata = array_replace_recursive($current, $incoming);
}
}
$joinToken->save();
return new EventJoinTokenResource($joinToken->fresh());
}
public function destroy(Request $request, Event $event, EventJoinToken $joinToken): EventJoinTokenResource
{
$this->authorizeEvent($request, $event);
@@ -68,4 +108,54 @@ class EventJoinTokenController extends Controller
abort(404, 'Event not found');
}
}
private function validatePayload(Request $request, bool $partial = false): array
{
$rules = [
'label' => [$partial ? 'nullable' : 'sometimes', 'string', 'max:255'],
'expires_at' => [$partial ? 'nullable' : 'sometimes', 'nullable', 'date', 'after:now'],
'usage_limit' => [$partial ? 'nullable' : 'sometimes', 'nullable', 'integer', 'min:1'],
'metadata' => [$partial ? 'nullable' : 'sometimes', 'nullable', 'array'],
'metadata.layout_customization' => ['nullable', 'array'],
'metadata.layout_customization.layout_id' => ['nullable', 'string', 'max:100'],
'metadata.layout_customization.headline' => ['nullable', 'string', 'max:120'],
'metadata.layout_customization.subtitle' => ['nullable', 'string', 'max:160'],
'metadata.layout_customization.description' => ['nullable', 'string', 'max:500'],
'metadata.layout_customization.badge_label' => ['nullable', 'string', 'max:80'],
'metadata.layout_customization.instructions_heading' => ['nullable', 'string', 'max:120'],
'metadata.layout_customization.link_heading' => ['nullable', 'string', 'max:120'],
'metadata.layout_customization.cta_label' => ['nullable', 'string', 'max:120'],
'metadata.layout_customization.cta_caption' => ['nullable', 'string', 'max:160'],
'metadata.layout_customization.link_label' => ['nullable', 'string', 'max:160'],
'metadata.layout_customization.instructions' => ['nullable', 'array', 'max:6'],
'metadata.layout_customization.instructions.*' => ['nullable', 'string', 'max:160'],
'metadata.layout_customization.logo_url' => ['nullable', 'string', 'max:2048'],
'metadata.layout_customization.logo_data_url' => ['nullable', 'string'],
'metadata.layout_customization.accent_color' => ['nullable', 'string', 'regex:/^#([A-Fa-f0-9]{3}|[A-Fa-f0-9]{6})$/'],
'metadata.layout_customization.text_color' => ['nullable', 'string', 'regex:/^#([A-Fa-f0-9]{3}|[A-Fa-f0-9]{6})$/'],
'metadata.layout_customization.background_color' => ['nullable', 'string', 'regex:/^#([A-Fa-f0-9]{3}|[A-Fa-f0-9]{6})$/'],
'metadata.layout_customization.secondary_color' => ['nullable', 'string', 'regex:/^#([A-Fa-f0-9]{3}|[A-Fa-f0-9]{6})$/'],
'metadata.layout_customization.badge_color' => ['nullable', 'string', 'regex:/^#([A-Fa-f0-9]{3}|[A-Fa-f0-9]{6})$/'],
'metadata.layout_customization.background_gradient' => ['nullable', 'array'],
'metadata.layout_customization.background_gradient.angle' => ['nullable', 'numeric'],
'metadata.layout_customization.background_gradient.stops' => ['nullable', 'array', 'max:5'],
'metadata.layout_customization.background_gradient.stops.*' => ['nullable', 'string', 'regex:/^#([A-Fa-f0-9]{3}|[A-Fa-f0-9]{6})$/'],
];
$validated = $request->validate($rules);
if (isset($validated['metadata']['layout_customization']['instructions'])) {
$validated['metadata']['layout_customization']['instructions'] = array_values(array_filter(
$validated['metadata']['layout_customization']['instructions'],
fn ($value) => is_string($value) && trim($value) !== ''
));
}
if (isset($validated['metadata']['layout_customization']['logo_data_url'])
&& ! is_string($validated['metadata']['layout_customization']['logo_data_url'])) {
unset($validated['metadata']['layout_customization']['logo_data_url']);
}
return $validated;
}
}

View File

@@ -46,6 +46,8 @@ class EventJoinTokenLayoutController extends Controller
abort(404, 'Unbekanntes Exportformat.');
}
$layoutConfig = $this->applyCustomization($layoutConfig, $joinToken);
$tokenUrl = url('/e/'.$joinToken->token);
$qrPngDataUri = 'data:image/png;base64,'.base64_encode(
@@ -66,6 +68,7 @@ class EventJoinTokenLayoutController extends Controller
'tokenUrl' => $tokenUrl,
'qrPngDataUri' => $qrPngDataUri,
'backgroundStyle' => $backgroundStyle,
'customization' => $joinToken->metadata['layout_customization'] ?? null,
];
$filename = sprintf('%s-%s.%s', Str::slug($eventName ?: 'event'), $layoutConfig['id'], $format);
@@ -80,7 +83,7 @@ class EventJoinTokenLayoutController extends Controller
$html = view('layouts.join-token.pdf', $viewData)->render();
$options = new Options();
$options = new Options;
$options->set('isHtml5ParserEnabled', true);
$options->set('isRemoteEnabled', true);
$options->set('defaultFont', 'Helvetica');
@@ -115,6 +118,57 @@ class EventJoinTokenLayoutController extends Controller
return is_string($name) && $name !== '' ? $name : 'Event';
}
private function applyCustomization(array $layout, EventJoinToken $joinToken): array
{
$customization = data_get($joinToken->metadata, 'layout_customization');
if (! is_array($customization)) {
return $layout;
}
$layoutId = $customization['layout_id'] ?? null;
if (is_string($layoutId) && isset($layout['id']) && $layoutId !== $layout['id']) {
// Allow customization to target a specific layout; if mismatch, skip style overrides.
// General text overrides are still applied below.
}
$colorKeys = [
'accent' => 'accent_color',
'text' => 'text_color',
'background' => 'background_color',
'secondary' => 'secondary_color',
'badge' => 'badge_color',
];
foreach ($colorKeys as $layoutKey => $customKey) {
if (isset($customization[$customKey]) && is_string($customization[$customKey])) {
$layout[$layoutKey] = $customization[$customKey];
}
}
if (isset($customization['background_gradient']) && is_array($customization['background_gradient'])) {
$layout['background_gradient'] = $customization['background_gradient'];
}
foreach (['headline' => 'name', 'subtitle', 'description', 'badge_label', 'instructions_heading', 'link_heading', 'cta_label', 'cta_caption', 'link_label'] as $customKey => $layoutKey) {
if (isset($customization[$customKey]) && is_string($customization[$customKey])) {
$layout[$layoutKey] = $customization[$customKey];
}
}
if (array_key_exists('instructions', $customization) && is_array($customization['instructions'])) {
$layout['instructions'] = array_values(array_filter($customization['instructions'], fn ($value) => is_string($value) && trim($value) !== ''));
}
if (! empty($customization['logo_data_url']) && is_string($customization['logo_data_url'])) {
$layout['logo_url'] = $customization['logo_data_url'];
} elseif (! empty($customization['logo_url']) && is_string($customization['logo_url'])) {
$layout['logo_url'] = $customization['logo_url'];
}
return $layout;
}
private function buildBackgroundStyle(array $layout): string
{
$gradient = $layout['background_gradient'] ?? null;
@@ -128,4 +182,4 @@ class EventJoinTokenLayoutController extends Controller
return $layout['background'] ?? '#FFFFFF';
}
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Http\Controllers\Api\Tenant;
use App\Http\Controllers\Controller;
use App\Models\Event;
use App\Models\TenantFeedback;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
class TenantFeedbackController extends Controller
{
public function store(Request $request): JsonResponse
{
$tenantId = $request->attributes->get('tenant_id');
if (! $tenantId) {
abort(403, 'Unauthorised');
}
$validated = $request->validate([
'category' => ['required', 'string', 'max:80'],
'sentiment' => ['nullable', 'string', Rule::in(['positive', 'neutral', 'negative'])],
'rating' => ['nullable', 'integer', 'min:1', 'max:5'],
'title' => ['nullable', 'string', 'max:120'],
'message' => ['nullable', 'string', 'max:2000'],
'event_slug' => ['nullable', 'string', 'max:255'],
'metadata' => ['nullable', 'array'],
]);
$eventId = null;
if (! empty($validated['event_slug'])) {
$eventSlug = $validated['event_slug'];
$event = Event::query()
->where('slug', $eventSlug)
->where('tenant_id', $tenantId)
->select('id')
->first();
$eventId = $event?->id;
}
$feedback = TenantFeedback::create([
'tenant_id' => $tenantId,
'event_id' => $eventId,
'category' => $validated['category'],
'sentiment' => $validated['sentiment'] ?? null,
'rating' => $validated['rating'] ?? null,
'title' => $validated['title'] ?? null,
'message' => $validated['message'] ?? null,
'metadata' => $validated['metadata'] ?? null,
]);
return response()->json([
'message' => 'Feedback gespeichert',
'data' => [
'id' => $feedback->id,
'created_at' => $feedback->created_at?->toIso8601String(),
],
], 201);
}
}

View File

@@ -7,22 +7,25 @@ use App\Models\OAuthCode;
use App\Models\RefreshToken;
use App\Models\Tenant;
use App\Models\TenantToken;
use Firebase\JWT\JWT;
use GuzzleHttp\Client;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Firebase\JWT\JWT;
use GuzzleHttp\Client;
use Illuminate\Support\Facades\Log;
class OAuthController extends Controller
{
private const AUTH_CODE_TTL_MINUTES = 5;
private const ACCESS_TOKEN_TTL_SECONDS = 3600;
private const REFRESH_TOKEN_TTL_DAYS = 30;
private const LEGACY_TOKEN_HEADER_KID = 'fotospiel-jwt';
/**
@@ -104,6 +107,14 @@ class OAuthController extends Controller
'state' => $request->state,
]);
if ($this->shouldReturnJsonAuthorizeResponse($request)) {
return response()->json([
'code' => $code,
'state' => $request->state,
'redirect_url' => $redirectUrl,
]);
}
return redirect()->away($redirectUrl);
}
@@ -402,6 +413,40 @@ class OAuthController extends Controller
];
}
private function shouldReturnJsonAuthorizeResponse(Request $request): bool
{
if ($request->expectsJson() || $request->ajax()) {
return true;
}
$redirectUri = (string) $request->string('redirect_uri');
$redirectHost = $redirectUri !== '' ? parse_url($redirectUri, PHP_URL_HOST) : null;
$requestHost = $request->getHost();
if ($redirectHost && ! $this->hostsMatch($requestHost, $redirectHost)) {
return true;
}
$origin = $request->headers->get('Origin');
if ($origin) {
$originHost = parse_url($origin, PHP_URL_HOST);
if ($originHost && $redirectHost && ! $this->hostsMatch($originHost, $redirectHost)) {
return true;
}
}
return false;
}
private function hostsMatch(?string $first, ?string $second): bool
{
if (! $first || ! $second) {
return false;
}
return strtolower($first) === strtolower($second);
}
private function createRefreshToken(Tenant $tenant, OAuthClient $client, array $scopes, string $accessTokenJti, Request $request): string
{
$refreshTokenId = (string) Str::uuid();
@@ -566,6 +611,7 @@ class OAuthController extends Controller
File::put($directory.DIRECTORY_SEPARATOR.'public.key', $publicKey, true);
File::chmod($directory.DIRECTORY_SEPARATOR.'public.key', 0644);
}
private function scopesAreAllowed(array $requestedScopes, array $availableScopes): bool
{
if (empty($requestedScopes)) {
@@ -682,7 +728,7 @@ class OAuthController extends Controller
return redirect('/event-admin')->with('error', 'Invalid state parameter');
}
$client = new Client();
$client = new Client;
$clientId = config('services.stripe.connect_client_id');
$secret = config('services.stripe.connect_secret');
$redirectUri = url('/api/v1/oauth/stripe-callback');
@@ -710,11 +756,12 @@ class OAuthController extends Controller
}
session()->forget(['stripe_state', 'tenant_id']);
return redirect('/event-admin')->with('success', 'Stripe account connected successfully');
} catch (\Exception $e) {
Log::error('Stripe OAuth error: '.$e->getMessage());
return redirect('/event-admin')->with('error', 'Connection error: '.$e->getMessage());
}
}
}