where('slug', $slug)->where('status', 'published')->first([ 'id', 'slug', 'name', 'default_locale', 'created_at', 'updated_at' ]); if (! $event) { return response()->json(['error' => ['code' => 'not_found', 'message' => 'Event not found or not public']], 404); } $locale = request()->query('locale', 'de'); $nameData = json_decode($event->name, true); $localizedName = $nameData[$locale] ?? $nameData['de'] ?? $event->name; // Get event type for icon $eventType = DB::table('events') ->join('event_types', 'events.event_type_id', '=', 'event_types.id') ->where('events.id', $event->id) ->first(['event_types.slug as type_slug', 'event_types.name as type_name']); $locale = request()->query('locale', 'de'); $eventTypeData = $eventType ? [ 'slug' => $eventType->type_slug, 'name' => $this->getLocalized($eventType->type_name, $locale, 'Event'), 'icon' => $eventType->type_slug === 'wedding' ? '❤️' : '👥' ] : [ 'slug' => 'general', 'name' => $this->getLocalized('Event', $locale, 'Event'), 'icon' => '👥' ]; 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, ])->header('Cache-Control', 'no-store'); } public function stats(string $slug) { $event = DB::table('events')->where('slug', $slug)->where('status', 'published')->first(['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 $slug) { $event = DB::table('events')->where('slug', $slug)->where('status', 'published')->first(['id']); if (! $event) { return response()->json(['error' => ['code' => 'not_found', 'message' => 'Event not found or not public']], 404); } $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', $event->id) ->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 $slug, Request $request) { $event = DB::table('events')->where('slug', $slug)->where('status', 'published')->first(['id']); if (! $event) { return response()->json(['error' => ['code' => 'not_found', 'message' => 'Event not found or not public']], 404); } $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', $event->id) ->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 $slug) { $event = DB::table('events')->where('slug', $slug)->where('status', 'published')->first(['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.created_at', '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('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 $slug) { $event = DB::table('events')->where('slug', $slug)->where('status', 'published')->first(['id']); if (! $event) { return response()->json(['error' => ['code' => 'not_found', 'message' => 'Event not found or not public']], 404); } $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', $event->id)->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/{$event->id}/photos", $file); $url = Storage::url($path); // Generate thumbnail (JPEG) under photos/thumbs $baseName = pathinfo($path, PATHINFO_FILENAME); $thumbRel = "events/{$event->id}/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' => $event->id, '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, $event->id), '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) } }