where('slug', $identifier) ->where('status', 'published') ->first($columns); if ($event) { return [$event, null]; } $joinToken = $this->joinTokenService->findActiveToken($identifier); if (! $joinToken) { return [null, null]; } $event = DB::table('events') ->where('id', $joinToken->event_id) ->where('status', 'published') ->first($columns); if (! $event) { return [null, null]; } return [$event, $joinToken]; } private function getLocalized($value, $locale, $default = '') { if (is_string($value) && json_decode($value) !== null) { $data = json_decode($value, true); return $data[$locale] ?? $data['de'] ?? $default; } return $value ?: $default; } private function toPublicUrl(?string $path): ?string { if (! $path) return null; // Already absolute URL if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://')) return $path; // Already a public storage URL if (str_starts_with($path, '/storage/')) return $path; if (str_starts_with($path, 'storage/')) return '/' . $path; // Common relative paths stored in DB (e.g. 'photos/...', 'thumbnails/...', 'events/...') if (str_starts_with($path, 'photos/') || str_starts_with($path, 'thumbnails/') || str_starts_with($path, 'events/')) { return Storage::url($path); } // Absolute server paths pointing into storage/app/public (Linux/Windows) $normalized = str_replace('\\', '/', $path); $needle = '/storage/app/public/'; if (str_contains($normalized, $needle)) { $rel = substr($normalized, strpos($normalized, $needle) + strlen($needle)); return '/storage/' . ltrim($rel, '/'); } return $path; // fallback as-is } public function event(string $identifier) { [$event, $joinToken] = $this->resolvePublishedEvent($identifier, [ 'id', 'slug', 'name', 'default_locale', 'created_at', 'updated_at', 'event_type_id', ]); if (! $event) { return response()->json(['error' => ['code' => 'not_found', 'message' => 'Event not found or not public']], 404); } $locale = request()->query('locale', $event->default_locale ?? 'de'); $nameData = json_decode($event->name, true); $localizedName = $nameData[$locale] ?? $nameData['de'] ?? $event->name; $eventType = null; if ($event->event_type_id) { $eventType = DB::table('event_types') ->where('id', $event->event_type_id) ->first(['slug as type_slug', 'name as type_name']); } $eventTypeData = $eventType ? [ 'slug' => $eventType->type_slug, 'name' => $this->getLocalized($eventType->type_name, $locale, 'Event'), 'icon' => $eventType->type_slug === 'wedding' ? 'heart' : 'guests', ] : [ 'slug' => 'general', 'name' => $this->getLocalized('Event', $locale, 'Event'), 'icon' => 'guests', ]; if ($joinToken) { $this->joinTokenService->incrementUsage($joinToken); } return response()->json([ 'id' => $event->id, 'slug' => $event->slug, 'name' => $localizedName, 'default_locale' => $event->default_locale, 'created_at' => $event->created_at, 'updated_at' => $event->updated_at, 'type' => $eventTypeData, 'join_token' => $joinToken?->token, ])->header('Cache-Control', 'no-store'); } public function stats(string $identifier) { [$event] = $this->resolvePublishedEvent($identifier, ['id']); if (! $event) { return response()->json(['error' => ['code' => 'not_found', 'message' => 'Event not found or not public']], 404); } $eventId = $event->id; // Approximate online guests as distinct recent uploaders in last 10 minutes. $tenMinutesAgo = CarbonImmutable::now()->subMinutes(10); $onlineGuests = DB::table('photos') ->where('event_id', $eventId) ->where('created_at', '>=', $tenMinutesAgo) ->distinct('guest_name') ->count('guest_name'); // Tasks solved as number of photos linked to a task (proxy metric). $tasksSolved = DB::table('photos')->where('event_id', $eventId)->whereNotNull('task_id')->count(); $latestPhotoAt = DB::table('photos')->where('event_id', $eventId)->max('created_at'); $payload = [ 'online_guests' => $onlineGuests, 'tasks_solved' => $tasksSolved, 'latest_photo_at' => $latestPhotoAt, ]; $etag = sha1(json_encode($payload)); $reqEtag = request()->headers->get('If-None-Match'); if ($reqEtag && $reqEtag === $etag) { return response('', 304); } return response()->json($payload) ->header('Cache-Control', 'no-store') ->header('ETag', $etag); } public function emotions(string $identifier) { [$event] = $this->resolvePublishedEvent($identifier, ['id']); if (! $event) { return response()->json(['error' => ['code' => 'not_found', 'message' => 'Event not found or not public']], 404); } $eventId = $event->id; $rows = DB::table('emotions') ->join('emotion_event_type', 'emotions.id', '=', 'emotion_event_type.emotion_id') ->join('event_types', 'emotion_event_type.event_type_id', '=', 'event_types.id') ->join('events', 'events.event_type_id', '=', 'event_types.id') ->where('events.id', $eventId) ->select([ 'emotions.id', 'emotions.name', 'emotions.icon as emoji', 'emotions.description' ]) ->orderBy('emotions.sort_order') ->get(); $payload = $rows->map(function ($r) { $locale = request()->query('locale', 'de'); $nameData = json_decode($r->name, true); $name = $nameData[$locale] ?? $nameData['de'] ?? $r->name; $descriptionData = json_decode($r->description, true); $description = $descriptionData ? ($descriptionData[$locale] ?? $descriptionData['de'] ?? '') : ''; return [ 'id' => (int) $r->id, 'slug' => 'emotion-' . $r->id, // Generate slug from ID 'name' => $name, 'emoji' => $r->emoji, 'description' => $description, ]; }); $etag = sha1($payload->toJson()); $reqEtag = request()->headers->get('If-None-Match'); if ($reqEtag && $reqEtag === $etag) { return response('', 304); } return response()->json($payload) ->header('Cache-Control', 'public, max-age=300') ->header('ETag', $etag); } public function tasks(string $identifier, Request $request) { [$event] = $this->resolvePublishedEvent($identifier, ['id']); if (! $event) { return response()->json(['error' => ['code' => 'not_found', 'message' => 'Event not found or not public']], 404); } $eventId = $event->id; $query = DB::table('tasks') ->join('task_collection_task', 'tasks.id', '=', 'task_collection_task.task_id') ->join('event_task_collection', 'task_collection_task.task_collection_id', '=', 'event_task_collection.task_collection_id') ->where('event_task_collection.event_id', $eventId) ->select([ 'tasks.id', 'tasks.title', 'tasks.description', 'tasks.example_text as instructions', 'tasks.emotion_id', 'tasks.sort_order' ]) ->orderBy('event_task_collection.sort_order') ->orderBy('tasks.sort_order') ->limit(20); $rows = $query->get(); $payload = $rows->map(function ($r) { // Handle JSON fields for multilingual content $getLocalized = function ($field, $default = '') use ($r) { $locale = request()->query('locale', 'de'); $value = $r->$field; if (is_string($value) && json_decode($value) !== null) { $data = json_decode($value, true); return $data[$locale] ?? $data['de'] ?? $default; } return $value ?: $default; }; // Get emotion info if emotion_id exists $emotion = null; if ($r->emotion_id) { $emotionRow = DB::table('emotions')->where('id', $r->emotion_id)->first(['name']); if ($emotionRow && isset($emotionRow->name)) { $locale = request()->query('locale', 'de'); $value = $emotionRow->name; if (is_string($value) && json_decode($value) !== null) { $data = json_decode($value, true); $emotionName = $data[$locale] ?? $data['de'] ?? 'Unbekannte Emotion'; } else { $emotionName = $value ?: 'Unbekannte Emotion'; } $emotion = [ 'slug' => 'emotion-' . $r->emotion_id, // Generate slug from ID 'name' => $emotionName, ]; } } return [ 'id' => (int) $r->id, 'title' => $getLocalized('title', 'Unbenannte Aufgabe'), 'description' => $getLocalized('description', ''), 'instructions' => $getLocalized('instructions', ''), 'duration' => 3, // Default 3 minutes (no duration field in DB) 'is_completed' => false, // Default false (no is_completed field in DB) 'emotion' => $emotion, ]; }); // If no tasks found, return empty array instead of 404 if ($payload->isEmpty()) { $payload = collect([]); } $etag = sha1($payload->toJson()); $reqEtag = request()->headers->get('If-None-Match'); if ($reqEtag && $reqEtag === $etag) { return response('', 304); } return response()->json($payload) ->header('Cache-Control', 'public, max-age=300') ->header('ETag', $etag); } public function photos(Request $request, string $identifier) { [$event] = $this->resolvePublishedEvent($identifier, ['id']); if (! $event) { return response()->json(['error' => ['code' => 'not_found', 'message' => 'Event not found or not public']], 404); } $eventId = $event->id; $deviceId = (string) $request->header('X-Device-Id', 'anon'); $filter = $request->query('filter'); $since = $request->query('since'); $query = DB::table('photos') ->leftJoin('tasks', 'photos.task_id', '=', 'tasks.id') ->select([ 'photos.id', 'photos.file_path', 'photos.thumbnail_path', 'photos.likes_count', 'photos.emotion_id', 'photos.task_id', 'photos.guest_name', 'tasks.title as task_title' ]) ->where('photos.event_id', $eventId) ->orderByDesc('photos.created_at') ->limit(60); // MyPhotos filter if ($filter === 'myphotos' && $deviceId !== 'anon') { $query->where('guest_name', $deviceId); } if ($since) { $query->where('photos.created_at', '>', $since); } $locale = request()->query('locale', 'de'); $rows = $query->get()->map(function ($r) use ($locale) { $r->file_path = $this->toPublicUrl((string)($r->file_path ?? '')); $r->thumbnail_path = $this->toPublicUrl((string)($r->thumbnail_path ?? '')); // Localize task title if present if ($r->task_title) { $r->task_title = $this->getLocalized($r->task_title, $locale, 'Unbenannte Aufgabe'); } return $r; }); $latestPhotoAt = DB::table('photos')->where('event_id', $eventId)->max('created_at'); $payload = [ 'data' => $rows, 'latest_photo_at' => $latestPhotoAt, ]; $etag = sha1(json_encode([$since, $filter, $deviceId, $latestPhotoAt])); $reqEtag = request()->headers->get('If-None-Match'); if ($reqEtag && $reqEtag === $etag) { return response('', 304); } return response()->json($payload) ->header('Cache-Control', 'no-store') ->header('ETag', $etag) ->header('Last-Modified', (string)$latestPhotoAt); } public function photo(int $id) { $row = DB::table('photos') ->join('events', 'photos.event_id', '=', 'events.id') ->leftJoin('tasks', 'photos.task_id', '=', 'tasks.id') ->select([ 'photos.id', 'photos.event_id', 'photos.file_path', 'photos.thumbnail_path', 'photos.likes_count', 'photos.emotion_id', 'photos.task_id', 'photos.created_at', 'tasks.title as task_title' ]) ->where('photos.id', $id) ->where('events.status', 'published') ->first(); if (! $row) { return response()->json(['error' => ['code' => 'not_found', 'message' => 'Photo not found or event not public']], 404); } $row->file_path = $this->toPublicUrl((string)($row->file_path ?? '')); $row->thumbnail_path = $this->toPublicUrl((string)($row->thumbnail_path ?? '')); // Localize task title if present $locale = request()->query('locale', 'de'); if ($row->task_title) { $row->task_title = $this->getLocalized($row->task_title, $locale, 'Unbenannte Aufgabe'); } return response()->json($row)->header('Cache-Control', 'no-store'); } public function like(Request $request, int $id) { $deviceId = (string) $request->header('X-Device-Id', 'anon'); $deviceId = substr(preg_replace('/[^a-zA-Z0-9_-]/', '', $deviceId), 0, 64); if ($deviceId === '') { $deviceId = 'anon'; } $photo = DB::table('photos') ->join('events', 'photos.event_id', '=', 'events.id') ->where('photos.id', $id) ->where('events.status', 'published') ->first(['photos.id', 'photos.event_id']); if (! $photo) { return response()->json(['error' => ['code' => 'not_found', 'message' => 'Photo not found or event not public']], 404); } // Idempotent like per device $exists = DB::table('photo_likes')->where('photo_id', $id)->where('guest_name', $deviceId)->exists(); if ($exists) { $count = (int) DB::table('photos')->where('id', $id)->value('likes_count'); return response()->json(['liked' => true, 'likes_count' => $count]); } DB::beginTransaction(); try { DB::table('photo_likes')->insert([ 'photo_id' => $id, 'guest_name' => $deviceId, 'ip_address' => 'device', 'created_at' => now(), ]); DB::table('photos')->where('id', $id)->update([ 'likes_count' => DB::raw('likes_count + 1'), 'updated_at' => now(), ]); DB::commit(); } catch (\Throwable $e) { DB::rollBack(); Log::warning('like failed', ['error' => $e->getMessage()]); } $count = (int) DB::table('photos')->where('id', $id)->value('likes_count'); return response()->json(['liked' => true, 'likes_count' => $count]); } public function upload(Request $request, string $identifier) { [$event] = $this->resolvePublishedEvent($identifier, ['id']); if (! $event) { return response()->json(['error' => ['code' => 'not_found', 'message' => 'Event not found or not public']], 404); } $eventId = $event->id; $deviceId = (string) $request->header('X-Device-Id', 'anon'); $deviceId = substr(preg_replace('/[^a-zA-Z0-9_-]/', '', $deviceId), 0, 64) ?: 'anon'; // Per-device cap per event (MVP: 50) $deviceCount = DB::table('photos')->where('event_id', $eventId)->where('guest_name', $deviceId)->count(); if ($deviceCount >= 50) { return response()->json(['error' => ['code' => 'limit_reached', 'message' => 'Upload-Limit erreicht']], 429); } $validated = $request->validate([ 'photo' => ['required', 'image', 'max:6144'], // 6 MB 'emotion_id' => ['nullable', 'integer'], 'emotion_slug' => ['nullable', 'string'], 'task_id' => ['nullable', 'integer'], 'guest_name' => ['nullable', 'string', 'max:255'], ]); $file = $validated['photo']; $path = Storage::disk('public')->putFile("events/{$eventId}/photos", $file); $url = Storage::url($path); // Generate thumbnail (JPEG) under photos/thumbs $baseName = pathinfo($path, PATHINFO_FILENAME); $thumbRel = "events/{$eventId}/photos/thumbs/{$baseName}_thumb.jpg"; $thumbPath = ImageHelper::makeThumbnailOnDisk('public', $path, $thumbRel, 640, 82); $thumbUrl = $thumbPath ? Storage::url($thumbPath) : $url; $id = DB::table('photos')->insertGetId([ 'event_id' => $eventId, 'task_id' => $validated['task_id'] ?? null, 'guest_name' => $validated['guest_name'] ?? $deviceId, 'file_path' => $url, 'thumbnail_path' => $thumbUrl, 'likes_count' => 0, // Handle emotion_id: prefer explicit ID, fallback to slug lookup, then default 'emotion_id' => $this->resolveEmotionId($validated, $eventId), 'is_featured' => 0, 'metadata' => null, 'created_at' => now(), 'updated_at' => now(), ]); return response()->json([ 'id' => $id, 'file_path' => $url, 'thumbnail_path' => $thumbUrl, ], 201); } /** * Resolve emotion_id from validation data, supporting both direct ID and slug lookup */ private function resolveEmotionId(array $validated, int $eventId): int { // 1. Use explicit emotion_id if provided if (isset($validated['emotion_id']) && $validated['emotion_id'] !== null) { return (int) $validated['emotion_id']; } // 2. Resolve from emotion_slug if provided (format: 'emotion-{id}') if (isset($validated['emotion_slug']) && $validated['emotion_slug']) { $slug = $validated['emotion_slug']; if (str_starts_with($slug, 'emotion-')) { $id = (int) substr($slug, 8); // Remove 'emotion-' prefix if ($id > 0) { // Verify emotion exists and is available for this event $exists = DB::table('emotions') ->join('emotion_event_type', 'emotions.id', '=', 'emotion_event_type.emotion_id') ->join('events', 'emotion_event_type.event_type_id', '=', 'events.event_type_id') ->where('emotions.id', $id) ->where('events.id', $eventId) ->exists(); if ($exists) { return $id; } } } } // 3. Fallback: Get first available emotion for this event type $defaultEmotion = DB::table('emotions') ->join('emotion_event_type', 'emotions.id', '=', 'emotion_event_type.emotion_id') ->join('events', 'emotion_event_type.event_type_id', '=', 'events.event_type_id') ->where('events.id', $eventId) ->orderBy('emotions.sort_order') ->value('emotions.id'); return $defaultEmotion ?: 1; // Ultimate fallback to emotion ID 1 (assuming "Happy" exists) } public function achievements(Request $request, string $identifier) { [$event] = $this->resolvePublishedEvent($identifier, ['id']); if (! $event) { return response()->json(['error' => ['code' => 'not_found', 'message' => 'Event not found or not public']], 404); } $eventId = $event->id; $locale = $request->query('locale', 'de'); $totalPhotos = (int) DB::table('photos')->where('event_id', $eventId)->count(); $uniqueGuests = (int) DB::table('photos')->where('event_id', $eventId)->distinct('guest_name')->count('guest_name'); $tasksSolved = (int) DB::table('photos')->where('event_id', $eventId)->whereNotNull('task_id')->count(); $likesTotal = (int) DB::table('photos')->where('event_id', $eventId)->sum('likes_count'); $summary = [ 'total_photos' => $totalPhotos, 'unique_guests' => $uniqueGuests, 'tasks_solved' => $tasksSolved, 'likes_total' => $likesTotal, ]; $guestNameParam = trim((string) $request->query('guest_name', '')); $deviceIdHeader = (string) $request->headers->get('X-Device-Id', ''); $deviceId = substr(preg_replace('/[^A-Za-z0-9 _\-]/', '', $deviceIdHeader), 0, 120); $candidate = $guestNameParam !== '' ? $guestNameParam : $deviceId; $guestIdentifier = $candidate !== '' ? substr(preg_replace('/[^A-Za-z0-9 _\-]/', '', $candidate), 0, 120) : null; $personal = null; if ($guestIdentifier) { $personalPhotos = (int) DB::table('photos') ->where('event_id', $eventId) ->where('guest_name', $guestIdentifier) ->count(); $personalTasks = (int) DB::table('photos') ->where('event_id', $eventId) ->where('guest_name', $guestIdentifier) ->whereNotNull('task_id') ->distinct('task_id') ->count('task_id'); $personalLikes = (int) DB::table('photos') ->where('event_id', $eventId) ->where('guest_name', $guestIdentifier) ->sum('likes_count'); $badgeBlueprints = [ ['id' => 'first_photo', 'title' => 'Erstes Foto', 'description' => 'Lade dein erstes Foto hoch.', 'target' => 1, 'metric' => 'photos'], ['id' => 'five_photos', 'title' => 'Fotoflair', 'description' => 'Fuenf Fotos hochgeladen.', 'target' => 5, 'metric' => 'photos'], ['id' => 'first_task', 'title' => 'Aufgabenstarter', 'description' => 'Erste Aufgabe erfuellt.', 'target' => 1, 'metric' => 'tasks'], ['id' => 'task_master', 'title' => 'Aufgabenmeister', 'description' => 'Fuenf Aufgaben erfuellt.', 'target' => 5, 'metric' => 'tasks'], ['id' => 'crowd_pleaser', 'title' => 'Publikumsliebling', 'description' => 'Sammle zwanzig Likes.', 'target' => 20, 'metric' => 'likes'], ]; $personalBadges = []; foreach ($badgeBlueprints as $badge) { $value = 0; if ($badge['metric'] === 'photos') { $value = $personalPhotos; } elseif ($badge['metric'] === 'tasks') { $value = $personalTasks; } else { $value = $personalLikes; } $personalBadges[] = [ 'id' => $badge['id'], 'title' => $badge['title'], 'description' => $badge['description'], 'earned' => $value >= $badge['target'], 'progress' => $value, 'target' => $badge['target'], ]; } $personal = [ 'guest_name' => $guestIdentifier, 'photos' => $personalPhotos, 'tasks' => $personalTasks, 'likes' => $personalLikes, 'badges' => $personalBadges, ]; } $topUploads = DB::table('photos') ->select('guest_name', DB::raw('COUNT(*) as total'), DB::raw('SUM(likes_count) as likes')) ->where('event_id', $eventId) ->groupBy('guest_name') ->orderByDesc('total') ->limit(5) ->get() ->map(fn ($row) => [ 'guest' => $row->guest_name, 'photos' => (int) $row->total, 'likes' => (int) $row->likes, ]) ->values(); $topLikes = DB::table('photos') ->select('guest_name', DB::raw('SUM(likes_count) as likes'), DB::raw('COUNT(*) as total')) ->where('event_id', $eventId) ->groupBy('guest_name') ->orderByDesc('likes') ->limit(5) ->get() ->map(fn ($row) => [ 'guest' => $row->guest_name, 'likes' => (int) $row->likes, 'photos' => (int) $row->total, ]) ->values(); $topPhotoRow = DB::table('photos') ->leftJoin('tasks', 'photos.task_id', '=', 'tasks.id') ->where('photos.event_id', $eventId) ->orderByDesc('photos.likes_count') ->orderByDesc('photos.created_at') ->select([ 'photos.id', 'photos.guest_name', 'photos.likes_count', 'photos.file_path', 'photos.thumbnail_path', 'photos.created_at', 'tasks.title as task_title', ]) ->first(); $topPhoto = $topPhotoRow ? [ 'photo_id' => (int) $topPhotoRow->id, 'guest' => $topPhotoRow->guest_name, 'likes' => (int) $topPhotoRow->likes_count, 'task' => $topPhotoRow->task_title, 'created_at' => $topPhotoRow->created_at, 'thumbnail' => $this->toPublicUrl($topPhotoRow->thumbnail_path ?: $topPhotoRow->file_path), ] : null; $trendingEmotionRow = DB::table('photos') ->join('emotions', 'photos.emotion_id', '=', 'emotions.id') ->where('photos.event_id', $eventId) ->select('emotions.id', 'emotions.name', DB::raw('COUNT(*) as total')) ->groupBy('emotions.id', 'emotions.name') ->orderByDesc('total') ->first(); $trendingEmotion = $trendingEmotionRow ? [ 'emotion_id' => (int) $trendingEmotionRow->id, 'name' => $this->getLocalized($trendingEmotionRow->name, $locale, $trendingEmotionRow->name), 'count' => (int) $trendingEmotionRow->total, ] : null; $timeline = DB::table('photos') ->where('event_id', $eventId) ->selectRaw('DATE(created_at) as day, COUNT(*) as photos, COUNT(DISTINCT guest_name) as guests') ->groupBy('day') ->orderBy('day') ->limit(14) ->get() ->map(fn ($row) => [ 'date' => $row->day, 'photos' => (int) $row->photos, 'guests' => (int) $row->guests, ]) ->values(); $feed = DB::table('photos') ->leftJoin('tasks', 'photos.task_id', '=', 'tasks.id') ->where('photos.event_id', $eventId) ->orderByDesc('photos.created_at') ->limit(12) ->get([ 'photos.id', 'photos.guest_name', 'photos.thumbnail_path', 'photos.file_path', 'photos.likes_count', 'photos.created_at', 'tasks.title as task_title', ]) ->map(fn ($row) => [ 'photo_id' => (int) $row->id, 'guest' => $row->guest_name, 'task' => $row->task_title, 'likes' => (int) $row->likes_count, 'created_at' => $row->created_at, 'thumbnail' => $this->toPublicUrl($row->thumbnail_path ?: $row->file_path), ]) ->values(); $payload = [ 'summary' => $summary, 'personal' => $personal, 'leaderboards' => [ 'uploads' => $topUploads, 'likes' => $topLikes, ], 'highlights' => [ 'top_photo' => $topPhoto, 'trending_emotion' => $trendingEmotion, 'timeline' => $timeline, ], 'feed' => $feed, ]; $etag = sha1(json_encode($payload)); $ifNoneMatch = $request->headers->get('If-None-Match'); if ($ifNoneMatch && $ifNoneMatch === $etag) { return response('', 304); } return response()->json($payload) ->header('Cache-Control', 'no-store') ->header('ETag', $etag); } }