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);
}
}

View File

@@ -4,10 +4,12 @@ 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\Models\Event;
use App\Models\Tenant;
use App\Models\Photo;
use App\Services\EventJoinTokenService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
@@ -18,6 +20,10 @@ use Illuminate\Validation\ValidationException;
class EventController extends Controller
{
public function __construct(private readonly EventJoinTokenService $joinTokenService)
{
}
public function index(Request $request): AnonymousResourceCollection
{
$tenantId = $request->attributes->get('tenant_id');
@@ -283,12 +289,26 @@ class EventController extends Controller
return response()->json(['error' => 'Event not found'], 404);
}
$token = (string) Str::uuid();
$link = url("/e/{$event->slug}?invite={$token}");
$validated = $request->validate([
'label' => ['nullable', 'string', 'max:255'],
'expires_at' => ['nullable', 'date', 'after:now'],
'usage_limit' => ['nullable', 'integer', 'min:1'],
]);
$attributes = array_filter([
'label' => $validated['label'] ?? null,
'expires_at' => $validated['expires_at'] ?? null,
'usage_limit' => $validated['usage_limit'] ?? null,
'created_by' => $request->user()?->id,
], fn ($value) => ! is_null($value));
$joinToken = $this->joinTokenService->createToken($event, $attributes);
return response()->json([
'link' => $link,
'token' => $token,
'link' => url("/e/{$event->slug}?invite={$joinToken->token}"),
'token' => $joinToken->token,
'token_url' => url('/e/'.$joinToken->token),
'join_token' => new EventJoinTokenResource($joinToken),
]);
}
public function bulkUpdateStatus(Request $request): JsonResponse

View File

@@ -0,0 +1,73 @@
<?php
namespace App\Http\Controllers\Api\Tenant;
use App\Http\Controllers\Controller;
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\Support\Facades\Auth;
class EventJoinTokenController extends Controller
{
public function __construct(private readonly EventJoinTokenService $joinTokenService)
{
}
public function index(Request $request, Event $event): JsonResponse
{
$this->authorizeEvent($request, $event);
$tokens = $event->joinTokens()
->orderByDesc('created_at')
->get();
return EventJoinTokenResource::collection($tokens);
}
public function store(Request $request, Event $event): JsonResponse
{
$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'],
]);
$token = $this->joinTokenService->createToken($event, array_merge($validated, [
'created_by' => Auth::id(),
]));
return (new EventJoinTokenResource($token))
->response()
->setStatusCode(201);
}
public function destroy(Request $request, Event $event, EventJoinToken $joinToken): JsonResponse
{
$this->authorizeEvent($request, $event);
if ($joinToken->event_id !== $event->id) {
abort(404);
}
$reason = $request->input('reason');
$token = $this->joinTokenService->revoke($joinToken, $reason);
return new EventJoinTokenResource($token);
}
private function authorizeEvent(Request $request, Event $event): void
{
$tenantId = $request->attributes->get('tenant_id');
if ($event->tenant_id !== $tenantId) {
abort(404, 'Event not found');
}
}
}

View File

@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api;
use App\Models\Event;
use App\Models\Photo;
use App\Models\User;
use App\Services\EventJoinTokenService;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller as BaseController;
use Illuminate\Support\Facades\Auth;
@@ -13,6 +14,10 @@ use Illuminate\Support\Str;
class TenantController extends BaseController
{
public function __construct(private readonly EventJoinTokenService $joinTokenService)
{
}
public function login(Request $request)
{
$creds = $request->validate([
@@ -145,7 +150,7 @@ class TenantController extends BaseController
]);
}
public function createInvite(int $id)
public function createInvite(Request $request, int $id)
{
$u = Auth::user();
$tenantId = $u->tenant_id ?? null;
@@ -153,10 +158,16 @@ class TenantController extends BaseController
if ($tenantId && $ev->tenant_id !== $tenantId) {
return response()->json(['error' => ['code' => 'forbidden']], 403);
}
$token = Str::random(32);
Cache::put('invite:'.$token, $ev->slug, now()->addDays(2));
$link = url('/e/'.$ev->slug).'?t='.$token;
return response()->json(['link' => $link]);
$joinToken = $this->joinTokenService->createToken($ev, [
'created_by' => $u?->id,
]);
return response()->json([
'link' => url('/e/'.$joinToken->token),
'legacy_link' => url('/e/'.$ev->slug).'?invite='.$joinToken->token,
'token' => $joinToken->token,
]);
}
public function eventPhotos(int $id)