feat: localize guest endpoints and caching
This commit is contained in:
@@ -9,6 +9,7 @@ use App\Models\Photo;
|
||||
use App\Models\PhotoShareLink;
|
||||
use App\Services\Analytics\JoinTokenAnalyticsRecorder;
|
||||
use App\Services\EventJoinTokenService;
|
||||
use App\Services\EventTasksCacheService;
|
||||
use App\Services\Packages\PackageLimitEvaluator;
|
||||
use App\Services\Packages\PackageUsageTracker;
|
||||
use App\Services\Storage\EventStorageManager;
|
||||
@@ -20,6 +21,7 @@ use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller as BaseController;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
@@ -38,6 +40,7 @@ class EventPublicController extends BaseController
|
||||
private readonly JoinTokenAnalyticsRecorder $analyticsRecorder,
|
||||
private readonly PackageLimitEvaluator $packageLimitEvaluator,
|
||||
private readonly PackageUsageTracker $packageUsageTracker,
|
||||
private readonly EventTasksCacheService $eventTasksCache,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -439,6 +442,380 @@ class EventPublicController extends BaseController
|
||||
return $value ?: $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{tasks: array<int, array<string, mixed>>, hash: string} $payload
|
||||
*/
|
||||
private function buildLocalizedTasksPayload(int $eventId, string $locale, ?string $eventDefaultLocale): array
|
||||
{
|
||||
$fallbacks = $this->localeFallbackChain($locale, $eventDefaultLocale);
|
||||
|
||||
$rows = 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')
|
||||
->leftJoin('emotions', 'tasks.emotion_id', '=', 'emotions.id')
|
||||
->where('event_task_collection.event_id', $eventId)
|
||||
->select([
|
||||
'tasks.id',
|
||||
'tasks.title',
|
||||
'tasks.description',
|
||||
'tasks.example_text',
|
||||
'tasks.emotion_id',
|
||||
'tasks.sort_order',
|
||||
'emotions.name as emotion_name',
|
||||
'emotions.id as emotion_lookup_id',
|
||||
])
|
||||
->orderBy('event_task_collection.sort_order')
|
||||
->orderBy('tasks.sort_order')
|
||||
->limit(20)
|
||||
->get();
|
||||
|
||||
$tasks = $rows->map(function ($row) use ($fallbacks) {
|
||||
$emotion = null;
|
||||
|
||||
if ($row->emotion_id) {
|
||||
$emotionName = $this->firstLocalizedValue($row->emotion_name, $fallbacks, 'Unbekannte Emotion');
|
||||
|
||||
if ($emotionName !== '') {
|
||||
$emotionId = (int) ($row->emotion_lookup_id ?? $row->emotion_id);
|
||||
$emotion = [
|
||||
'slug' => 'emotion-'.$emotionId,
|
||||
'name' => $emotionName,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => (int) $row->id,
|
||||
'title' => $this->firstLocalizedValue($row->title, $fallbacks, 'Unbenannte Aufgabe'),
|
||||
'description' => $this->firstLocalizedValue($row->description, $fallbacks, ''),
|
||||
'instructions' => $this->firstLocalizedValue($row->example_text, $fallbacks, ''),
|
||||
'duration' => 3,
|
||||
'is_completed' => false,
|
||||
'emotion' => $emotion,
|
||||
];
|
||||
})->values()->all();
|
||||
|
||||
return [
|
||||
'tasks' => $tasks,
|
||||
'hash' => sha1(json_encode($tasks)),
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveGuestLocale(Request $request, object $event): array
|
||||
{
|
||||
$supported = $this->eventTasksCache->supportedLocales();
|
||||
|
||||
$queryLocale = $this->normalizeLocale($request->query('locale', $request->query('lang')), $supported);
|
||||
if ($queryLocale) {
|
||||
return [$queryLocale, 'query'];
|
||||
}
|
||||
|
||||
$headerLocale = $this->normalizeLocale($request->header('X-Locale'), $supported);
|
||||
if ($headerLocale) {
|
||||
return [$headerLocale, 'header'];
|
||||
}
|
||||
|
||||
$acceptedLocale = $this->normalizeLocale($request->getPreferredLanguage($supported), $supported);
|
||||
if ($acceptedLocale) {
|
||||
return [$acceptedLocale, 'accept-language'];
|
||||
}
|
||||
|
||||
$eventLocale = $this->normalizeLocale($event->default_locale ?? null, $supported);
|
||||
if ($eventLocale) {
|
||||
return [$eventLocale, 'event'];
|
||||
}
|
||||
|
||||
$fallbackLocale = $this->normalizeLocale(config('app.fallback_locale'), $supported);
|
||||
if ($fallbackLocale) {
|
||||
return [$fallbackLocale, 'fallback'];
|
||||
}
|
||||
|
||||
return [$supported[0] ?? 'de', 'fallback'];
|
||||
}
|
||||
|
||||
private function localeFallbackChain(string $preferred, ?string $eventDefault): array
|
||||
{
|
||||
$chain = array_filter([
|
||||
$preferred,
|
||||
$this->normalizeLocale($eventDefault),
|
||||
$this->normalizeLocale(config('app.fallback_locale')),
|
||||
'de',
|
||||
'en',
|
||||
]);
|
||||
|
||||
return array_values(array_unique($chain));
|
||||
}
|
||||
|
||||
private function normalizeLocale(?string $value, ?array $allowed = null): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalized = Str::of($value)
|
||||
->lower()
|
||||
->replace('_', '-')
|
||||
->before('-')
|
||||
->trim()
|
||||
->value();
|
||||
|
||||
if ($normalized === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($allowed !== null && ! in_array($normalized, $allowed, true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
private function decodeTranslations(mixed $value): array
|
||||
{
|
||||
if (is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (is_string($value) && $value !== '') {
|
||||
$decoded = json_decode($value, true);
|
||||
|
||||
if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
|
||||
return $decoded;
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private function firstLocalizedValue(mixed $value, array $locales, string $default = ''): string
|
||||
{
|
||||
$translations = $this->decodeTranslations($value);
|
||||
|
||||
foreach ($locales as $locale) {
|
||||
$candidate = $translations[$locale] ?? null;
|
||||
|
||||
if (is_string($candidate) && trim($candidate) !== '') {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
if (is_string($value) && trim($value) !== '') {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (! empty($translations)) {
|
||||
$first = reset($translations);
|
||||
if (is_string($first) && trim($first) !== '') {
|
||||
return $first;
|
||||
}
|
||||
}
|
||||
|
||||
return $default;
|
||||
}
|
||||
|
||||
private function determineGuestIdentifier(Request $request): ?string
|
||||
{
|
||||
$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;
|
||||
|
||||
if ($candidate === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return substr(preg_replace('/[^A-Za-z0-9 _\-]/', '', $candidate), 0, 120);
|
||||
}
|
||||
|
||||
private function buildAchievementsPayload(int $eventId, ?string $guestIdentifier, array $fallbacks): array
|
||||
{
|
||||
$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,
|
||||
];
|
||||
|
||||
$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 = match ($badge['metric']) {
|
||||
'photos' => $personalPhotos,
|
||||
'tasks' => $personalTasks,
|
||||
default => $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' => $this->firstLocalizedValue($topPhotoRow->task_title, $fallbacks, '') ?: null,
|
||||
'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->firstLocalizedValue($trendingEmotionRow->name, $fallbacks, 'Emotion'),
|
||||
'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' => $this->firstLocalizedValue($row->task_title, $fallbacks, '') ?: null,
|
||||
'likes' => (int) $row->likes_count,
|
||||
'created_at' => $row->created_at,
|
||||
'thumbnail' => $this->toPublicUrl($row->thumbnail_path ?: $row->file_path),
|
||||
])
|
||||
->values();
|
||||
|
||||
return [
|
||||
'summary' => $summary,
|
||||
'personal' => $personal,
|
||||
'leaderboards' => [
|
||||
'uploads' => $topUploads,
|
||||
'likes' => $topLikes,
|
||||
],
|
||||
'highlights' => [
|
||||
'top_photo' => $topPhoto,
|
||||
'trending_emotion' => $trendingEmotion,
|
||||
'timeline' => $timeline,
|
||||
],
|
||||
'feed' => $feed,
|
||||
];
|
||||
}
|
||||
|
||||
private function toPublicUrl(?string $path): ?string
|
||||
{
|
||||
if (! $path) {
|
||||
@@ -1263,156 +1640,117 @@ class EventPublicController extends BaseController
|
||||
|
||||
public function emotions(Request $request, string $token)
|
||||
{
|
||||
$result = $this->resolvePublishedEvent($request, $token, ['id']);
|
||||
$result = $this->resolvePublishedEvent($request, $token, ['id', 'default_locale']);
|
||||
|
||||
if ($result instanceof JsonResponse) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
[$event, $joinToken] = $result;
|
||||
$eventId = $event->id;
|
||||
$locale = $request->query('locale', 'de');
|
||||
[$event] = $result;
|
||||
$eventId = (int) $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();
|
||||
[$locale] = $this->resolveGuestLocale($request, $event);
|
||||
$fallbacks = $this->localeFallbackChain($locale, $event->default_locale ?? null);
|
||||
|
||||
$payload = $rows->map(function ($r) use ($locale) {
|
||||
$nameData = json_decode($r->name, true);
|
||||
$name = $nameData[$locale] ?? $nameData['de'] ?? $r->name;
|
||||
$cacheKey = sprintf('event:%d:emotions:%s', $eventId, $locale);
|
||||
|
||||
$descriptionData = json_decode($r->description, true);
|
||||
$description = $descriptionData ? ($descriptionData[$locale] ?? $descriptionData['de'] ?? '') : '';
|
||||
$cached = Cache::remember($cacheKey, now()->addMinutes(5), function () use ($eventId, $fallbacks) {
|
||||
$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();
|
||||
|
||||
$data = $rows->map(function ($row) use ($fallbacks) {
|
||||
return [
|
||||
'id' => (int) $row->id,
|
||||
'slug' => 'emotion-'.$row->id,
|
||||
'name' => $this->firstLocalizedValue($row->name, $fallbacks, 'Emotion'),
|
||||
'emoji' => $row->emoji,
|
||||
'description' => $this->firstLocalizedValue($row->description, $fallbacks, ''),
|
||||
];
|
||||
})->values()->all();
|
||||
|
||||
return [
|
||||
'id' => (int) $r->id,
|
||||
'slug' => 'emotion-'.$r->id, // Generate slug from ID
|
||||
'name' => $name,
|
||||
'emoji' => $r->emoji,
|
||||
'description' => $description,
|
||||
'data' => $data,
|
||||
'hash' => sha1(json_encode($data)),
|
||||
];
|
||||
});
|
||||
|
||||
$etag = sha1($payload->toJson());
|
||||
$payload = $cached['data'];
|
||||
$etag = $cached['hash'];
|
||||
$reqEtag = $request->headers->get('If-None-Match');
|
||||
if ($reqEtag && $reqEtag === $etag) {
|
||||
return response('', 304);
|
||||
return response('', 304)
|
||||
->header('Cache-Control', 'public, max-age=300')
|
||||
->header('ETag', $etag)
|
||||
->header('Vary', 'Accept-Language, X-Locale')
|
||||
->header('X-Content-Locale', $locale);
|
||||
}
|
||||
|
||||
return response()->json($payload)
|
||||
->header('Cache-Control', 'public, max-age=300')
|
||||
->header('ETag', $etag);
|
||||
->header('ETag', $etag)
|
||||
->header('Vary', 'Accept-Language, X-Locale')
|
||||
->header('X-Content-Locale', $locale);
|
||||
}
|
||||
|
||||
public function tasks(Request $request, string $token)
|
||||
{
|
||||
$result = $this->resolvePublishedEvent($request, $token, ['id']);
|
||||
$result = $this->resolvePublishedEvent($request, $token, ['id', 'default_locale']);
|
||||
|
||||
if ($result instanceof JsonResponse) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
[$event, $joinToken] = $result;
|
||||
$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);
|
||||
[$resolvedLocale] = $this->resolveGuestLocale($request, $event);
|
||||
|
||||
$rows = $query->get();
|
||||
$locale = $request->query('locale', 'de');
|
||||
|
||||
$payload = $rows->map(function ($r) use ($locale) {
|
||||
// Handle JSON fields for multilingual content
|
||||
$getLocalized = function ($field, $default = '') use ($r, $locale) {
|
||||
$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)) {
|
||||
$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,
|
||||
];
|
||||
$cached = $this->eventTasksCache->remember((int) $event->id, $resolvedLocale, function () use ($event, $resolvedLocale) {
|
||||
return $this->buildLocalizedTasksPayload((int) $event->id, $resolvedLocale, $event->default_locale ?? null);
|
||||
});
|
||||
|
||||
// If no tasks found, return empty array instead of 404
|
||||
if ($payload->isEmpty()) {
|
||||
$payload = collect([]);
|
||||
}
|
||||
|
||||
$etag = sha1($payload->toJson());
|
||||
$tasks = $cached['tasks'];
|
||||
$etag = $cached['hash'] ?? sha1(json_encode($tasks));
|
||||
$reqEtag = $request->headers->get('If-None-Match');
|
||||
|
||||
if ($reqEtag && $reqEtag === $etag) {
|
||||
return response('', 304);
|
||||
return response('', 304)
|
||||
->header('Cache-Control', 'public, max-age=120')
|
||||
->header('Vary', 'Accept-Language, X-Locale')
|
||||
->header('X-Content-Locale', $resolvedLocale)
|
||||
->header('ETag', $etag);
|
||||
}
|
||||
|
||||
return response()->json($payload)
|
||||
->header('Cache-Control', 'public, max-age=300')
|
||||
->header('ETag', $etag);
|
||||
return response()->json($tasks)
|
||||
->header('Cache-Control', 'public, max-age=120')
|
||||
->header('ETag', $etag)
|
||||
->header('Vary', 'Accept-Language, X-Locale')
|
||||
->header('X-Content-Locale', $resolvedLocale);
|
||||
}
|
||||
|
||||
public function photos(Request $request, string $token)
|
||||
{
|
||||
$result = $this->resolvePublishedEvent($request, $token, ['id']);
|
||||
$result = $this->resolvePublishedEvent($request, $token, ['id', 'default_locale']);
|
||||
|
||||
if ($result instanceof JsonResponse) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
[$event, $joinToken] = $result;
|
||||
$eventId = $event->id;
|
||||
[$event] = $result;
|
||||
$eventId = (int) $event->id;
|
||||
|
||||
[$locale] = $this->resolveGuestLocale($request, $event);
|
||||
$fallbacks = $this->localeFallbackChain($locale, $event->default_locale ?? null);
|
||||
|
||||
$deviceId = (string) $request->header('X-Device-Id', 'anon');
|
||||
$filter = $request->query('filter');
|
||||
@@ -1446,15 +1784,13 @@ class EventPublicController extends BaseController
|
||||
if ($since) {
|
||||
$query->where('photos.created_at', '>', $since);
|
||||
}
|
||||
$locale = $request->query('locale', 'de');
|
||||
|
||||
$rows = $query->get()->map(function ($r) use ($locale) {
|
||||
$rows = $query->get()->map(function ($r) use ($fallbacks) {
|
||||
$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');
|
||||
$r->task_title = $this->firstLocalizedValue($r->task_title, $fallbacks, 'Unbenannte Aufgabe');
|
||||
}
|
||||
|
||||
$r->ingest_source = $r->ingest_source ?? Photo::SOURCE_UNKNOWN;
|
||||
@@ -1466,19 +1802,26 @@ class EventPublicController extends BaseController
|
||||
'data' => $rows,
|
||||
'latest_photo_at' => $latestPhotoAt,
|
||||
];
|
||||
$etag = sha1(json_encode([$since, $filter, $deviceId, $latestPhotoAt]));
|
||||
$etag = sha1(json_encode([$since, $filter, $deviceId, $latestPhotoAt, $locale]));
|
||||
$reqEtag = $request->headers->get('If-None-Match');
|
||||
if ($reqEtag && $reqEtag === $etag) {
|
||||
return response('', 304);
|
||||
return response('', 304)
|
||||
->header('Cache-Control', 'no-store')
|
||||
->header('ETag', $etag)
|
||||
->header('Last-Modified', (string) $latestPhotoAt)
|
||||
->header('Vary', 'Accept-Language, X-Locale, X-Device-Id')
|
||||
->header('X-Content-Locale', $locale);
|
||||
}
|
||||
|
||||
return response()->json($payload)
|
||||
->header('Cache-Control', 'no-store')
|
||||
->header('ETag', $etag)
|
||||
->header('Last-Modified', (string) $latestPhotoAt);
|
||||
->header('Last-Modified', (string) $latestPhotoAt)
|
||||
->header('Vary', 'Accept-Language, X-Locale, X-Device-Id')
|
||||
->header('X-Content-Locale', $locale);
|
||||
}
|
||||
|
||||
public function photo(int $id)
|
||||
public function photo(Request $request, int $id)
|
||||
{
|
||||
$row = DB::table('photos')
|
||||
->join('events', 'photos.event_id', '=', 'events.id')
|
||||
@@ -1493,6 +1836,7 @@ class EventPublicController extends BaseController
|
||||
'photos.task_id',
|
||||
'photos.created_at',
|
||||
'tasks.title as task_title',
|
||||
'events.default_locale',
|
||||
])
|
||||
->where('photos.id', $id)
|
||||
->where('events.status', 'published')
|
||||
@@ -1509,13 +1853,24 @@ class EventPublicController extends BaseController
|
||||
$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');
|
||||
$event = (object) [
|
||||
'id' => $row->event_id,
|
||||
'default_locale' => $row->default_locale ?? null,
|
||||
];
|
||||
|
||||
[$locale] = $this->resolveGuestLocale($request, $event);
|
||||
$fallbacks = $this->localeFallbackChain($locale, $event->default_locale ?? null);
|
||||
|
||||
if ($row->task_title) {
|
||||
$row->task_title = $this->getLocalized($row->task_title, $locale, 'Unbenannte Aufgabe');
|
||||
$row->task_title = $this->firstLocalizedValue($row->task_title, $fallbacks, 'Unbenannte Aufgabe');
|
||||
}
|
||||
|
||||
return response()->json($row)->header('Cache-Control', 'no-store');
|
||||
unset($row->default_locale);
|
||||
|
||||
return response()->json($row)
|
||||
->header('Cache-Control', 'no-store')
|
||||
->header('X-Content-Locale', $locale)
|
||||
->header('Vary', 'Accept-Language, X-Locale');
|
||||
}
|
||||
|
||||
public function like(Request $request, int $id)
|
||||
@@ -1791,220 +2146,53 @@ class EventPublicController extends BaseController
|
||||
|
||||
public function achievements(Request $request, string $identifier)
|
||||
{
|
||||
$result = $this->resolvePublishedEvent($request, $identifier, ['id']);
|
||||
$result = $this->resolvePublishedEvent($request, $identifier, ['id', 'default_locale']);
|
||||
|
||||
if ($result instanceof JsonResponse) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
[$event] = $result;
|
||||
$eventId = $event->id;
|
||||
$locale = $request->query('locale', 'de');
|
||||
$eventId = (int) $event->id;
|
||||
|
||||
$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');
|
||||
[$locale] = $this->resolveGuestLocale($request, $event);
|
||||
$fallbacks = $this->localeFallbackChain($locale, $event->default_locale ?? null);
|
||||
|
||||
$summary = [
|
||||
'total_photos' => $totalPhotos,
|
||||
'unique_guests' => $uniqueGuests,
|
||||
'tasks_solved' => $tasksSolved,
|
||||
'likes_total' => $likesTotal,
|
||||
];
|
||||
$guestIdentifier = $this->determineGuestIdentifier($request);
|
||||
|
||||
$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;
|
||||
$cacheKey = sprintf(
|
||||
'event:%d:achievements:%s:%s',
|
||||
$eventId,
|
||||
$locale,
|
||||
$guestIdentifier ? sha1($guestIdentifier) : 'public'
|
||||
);
|
||||
|
||||
$personal = null;
|
||||
if ($guestIdentifier) {
|
||||
$personalPhotos = (int) DB::table('photos')
|
||||
->where('event_id', $eventId)
|
||||
->where('guest_name', $guestIdentifier)
|
||||
->count();
|
||||
$cached = Cache::remember($cacheKey, now()->addSeconds(60), function () use ($eventId, $guestIdentifier, $fallbacks) {
|
||||
$payload = $this->buildAchievementsPayload($eventId, $guestIdentifier, $fallbacks);
|
||||
|
||||
$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'],
|
||||
return [
|
||||
'payload' => $payload,
|
||||
'hash' => sha1(json_encode($payload)),
|
||||
];
|
||||
});
|
||||
|
||||
$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));
|
||||
$payload = $cached['payload'];
|
||||
$etag = $cached['hash'];
|
||||
$ifNoneMatch = $request->headers->get('If-None-Match');
|
||||
|
||||
if ($ifNoneMatch && $ifNoneMatch === $etag) {
|
||||
return response('', 304);
|
||||
return response('', 304)
|
||||
->header('Cache-Control', 'public, max-age=60')
|
||||
->header('ETag', $etag)
|
||||
->header('Vary', 'Accept-Language, X-Locale, X-Device-Id')
|
||||
->header('X-Content-Locale', $locale);
|
||||
}
|
||||
|
||||
return response()->json($payload)
|
||||
->header('Cache-Control', 'no-store')
|
||||
->header('ETag', $etag);
|
||||
->header('Cache-Control', 'public, max-age=60')
|
||||
->header('ETag', $etag)
|
||||
->header('Vary', 'Accept-Language, X-Locale, X-Device-Id')
|
||||
->header('X-Content-Locale', $locale);
|
||||
}
|
||||
|
||||
private function resolveDiskUrl(string $disk, string $path): string
|
||||
|
||||
Reference in New Issue
Block a user