Added opaque join-token support across backend and frontend: new migration/model/service/endpoints, guest controllers now resolve tokens, and the demo seeder seeds a token. Tenant event details list/manage tokens with copy/revoke actions, and the guest PWA uses tokens end-to-end (routing, storage, uploads, achievements, etc.). Docs TODO updated to reflect completed steps.

This commit is contained in:
Codex Agent
2025-10-12 10:32:37 +02:00
parent d04e234ca0
commit 9394c3171e
73 changed files with 3277 additions and 911 deletions

View File

@@ -10,9 +10,45 @@ use Symfony\Component\HttpFoundation\Response;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use App\Support\ImageHelper;
use App\Services\EventJoinTokenService;
class EventPublicController extends BaseController
{
public function __construct(private readonly EventJoinTokenService $joinTokenService)
{
}
/**
* @return array{0: object|null, 1: \App\Models\EventJoinToken|null}
*/
private function resolvePublishedEvent(string $identifier, array $columns = ['id']): array
{
$event = DB::table('events')
->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);
@@ -45,36 +81,46 @@ class EventPublicController extends BaseController
return $path; // fallback as-is
}
public function event(string $slug)
public function event(string $identifier)
{
$event = DB::table('events')->where('slug', $slug)->where('status', 'published')->first([
'id', 'slug', 'name', 'default_locale', 'created_at', 'updated_at'
[$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', 'de');
$locale = request()->query('locale', $event->default_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']);
$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']);
}
$locale = request()->query('locale', 'de');
$eventTypeData = $eventType ? [
'slug' => $eventType->type_slug,
'name' => $this->getLocalized($eventType->type_name, $locale, 'Event'),
'icon' => $eventType->type_slug === 'wedding' ? '❤️' : '👥'
'icon' => $eventType->type_slug === 'wedding' ? 'heart' : 'guests',
] : [
'slug' => 'general',
'name' => $this->getLocalized('Event', $locale, 'Event'),
'icon' => '👥'
'icon' => 'guests',
];
if ($joinToken) {
$this->joinTokenService->incrementUsage($joinToken);
}
return response()->json([
'id' => $event->id,
'slug' => $event->slug,
@@ -83,12 +129,13 @@ class EventPublicController extends BaseController
'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 $slug)
public function stats(string $identifier)
{
$event = DB::table('events')->where('slug', $slug)->where('status', 'published')->first(['id']);
[$event] = $this->resolvePublishedEvent($identifier, ['id']);
if (! $event) {
return response()->json(['error' => ['code' => 'not_found', 'message' => 'Event not found or not public']], 404);
}
@@ -125,18 +172,20 @@ class EventPublicController extends BaseController
->header('ETag', $etag);
}
public function emotions(string $slug)
public function emotions(string $identifier)
{
$event = DB::table('events')->where('slug', $slug)->where('status', 'published')->first(['id']);
[$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', $event->id)
->where('events.id', $eventId)
->select([
'emotions.id',
'emotions.name',
@@ -174,17 +223,19 @@ class EventPublicController extends BaseController
->header('ETag', $etag);
}
public function tasks(string $slug, Request $request)
public function tasks(string $identifier, Request $request)
{
$event = DB::table('events')->where('slug', $slug)->where('status', 'published')->first(['id']);
[$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', $event->id)
->where('event_task_collection.event_id', $eventId)
->select([
'tasks.id',
'tasks.title',
@@ -258,9 +309,9 @@ class EventPublicController extends BaseController
->header('ETag', $etag);
}
public function photos(Request $request, string $slug)
public function photos(Request $request, string $identifier)
{
$event = DB::table('events')->where('slug', $slug)->where('status', 'published')->first(['id']);
[$event] = $this->resolvePublishedEvent($identifier, ['id']);
if (! $event) {
return response()->json(['error' => ['code' => 'not_found', 'message' => 'Event not found or not public']], 404);
}
@@ -404,18 +455,19 @@ class EventPublicController extends BaseController
return response()->json(['liked' => true, 'likes_count' => $count]);
}
public function upload(Request $request, string $slug)
public function upload(Request $request, string $identifier)
{
$event = DB::table('events')->where('slug', $slug)->where('status', 'published')->first(['id']);
[$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', $event->id)->where('guest_name', $deviceId)->count();
$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);
}
@@ -429,17 +481,17 @@ class EventPublicController extends BaseController
]);
$file = $validated['photo'];
$path = Storage::disk('public')->putFile("events/{$event->id}/photos", $file);
$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/{$event->id}/photos/thumbs/{$baseName}_thumb.jpg";
$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' => $event->id,
'event_id' => $eventId,
'task_id' => $validated['task_id'] ?? null,
'guest_name' => $validated['guest_name'] ?? $deviceId,
'file_path' => $url,
@@ -447,7 +499,7 @@ class EventPublicController extends BaseController
'likes_count' => 0,
// Handle emotion_id: prefer explicit ID, fallback to slug lookup, then default
'emotion_id' => $this->resolveEmotionId($validated, $event->id),
'emotion_id' => $this->resolveEmotionId($validated, $eventId),
'is_featured' => 0,
'metadata' => null,
'created_at' => now(),
@@ -502,9 +554,9 @@ class EventPublicController extends BaseController
return $defaultEmotion ?: 1; // Ultimate fallback to emotion ID 1 (assuming "Happy" exists)
}
public function achievements(Request $request, string $slug)
public function achievements(Request $request, string $identifier)
{
$event = DB::table('events')->where('slug', $slug)->where('status', 'published')->first(['id']);
[$event] = $this->resolvePublishedEvent($identifier, ['id']);
if (! $event) {
return response()->json(['error' => ['code' => 'not_found', 'message' => 'Event not found or not public']], 404);
}
@@ -718,4 +770,3 @@ class EventPublicController extends BaseController
->header('ETag', $etag);
}
}