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

@@ -73,7 +73,7 @@ class MigrateToPackages extends Command
'tenant_id' => $event->tenant_id,
'event_id' => $event->id,
'package_id' => $freePackage->id,
'type' => 'endcustomer_event',
'type' => 'endcustomer',
'provider_id' => 'migration',
'price' => 0,
'metadata' => ['migrated_from_credits' => true],

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)

View File

@@ -9,15 +9,12 @@ use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Stripe\Stripe;
use Stripe\Checkout\Session;
use Stripe\StripeClient;
use Exception;
use PayPalHttp\Client;
use PayPalHttp\HttpException;
use PayPalCheckout\OrdersCreateRequest;
use PayPalCheckout\OrdersCaptureRequest;
use PayPalCheckout\OrdersGetRequest;
use PayPalCheckout\Order;
use App\Models\Tenant;
use App\Models\BlogPost;
use App\Models\Package;
@@ -71,7 +68,7 @@ class MarketingController extends Controller
*/
public function buyPackages(Request $request, $packageId)
{
Log::info('Buy packages called', ['auth' => Auth::check(), 'package_id' => $packageId]);
Log::info('Buy packages called', ['auth' => Auth::check(), 'package_id' => $packageId, 'provider' => $request->input('provider', 'stripe')]);
$package = Package::findOrFail($packageId);
if (!Auth::check()) {
@@ -163,6 +160,8 @@ class MarketingController extends Controller
],
]);
Log::info('Stripe Checkout initiated', ['package_id' => $packageId, 'session_id' => $session->id, 'tenant_id' => $tenant->id]);
return redirect($session->url, 303);
}
@@ -212,6 +211,8 @@ class MarketingController extends Controller
$response = $ordersController->createOrder($createRequest);
$order = $response->result;
Log::info('PayPal Checkout initiated', ['package_id' => $packageId, 'order_id' => $order->id, 'tenant_id' => $tenant->id]);
session(['paypal_order_id' => $order->id]);
foreach ($order->links as $link) {
@@ -224,7 +225,7 @@ class MarketingController extends Controller
} catch (HttpException $e) {
Log::error('PayPal Orders API error: ' . $e->getMessage());
return back()->with('error', 'Zahlung fehlgeschlagen');
} catch (\Exception $e) {
} catch (Exception $e) {
Log::error('PayPal checkout error: ' . $e->getMessage());
return back()->with('error', 'Zahlung fehlgeschlagen');
}
@@ -282,6 +283,9 @@ class MarketingController extends Controller
*/
public function success(Request $request, $packageId = null)
{
$provider = session('paypal_order_id') ? 'paypal' : 'stripe';
Log::info('Payment Success: Provider processed', ['provider' => $provider, 'package_id' => $packageId]);
if (session('paypal_order_id')) {
$orderId = session('paypal_order_id');
$client = Client::create([
@@ -299,6 +303,8 @@ class MarketingController extends Controller
$captureResponse = $ordersController->captureOrder($captureRequest);
$capture = $captureResponse->result;
Log::info('PayPal Capture completed', ['order_id' => $orderId, 'status' => $capture->status]);
if ($capture->status === 'COMPLETED') {
$customId = $capture->purchaseUnits[0]->customId ?? null;
if ($customId) {
@@ -423,7 +429,7 @@ class MarketingController extends Controller
// Transform to array with translated strings for the current locale
$markdown = $postModel->getTranslation('content', $locale) ?? $postModel->getTranslation('content', 'de') ?? '';
$converter = new \League\CommonMark\CommonMarkConverter();
$converter = new CommonMarkConverter();
$contentHtml = (string) $converter->convert($markdown);
// Debug log for content_html

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Http\Resources\Tenant;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class EventJoinTokenResource extends JsonResource
{
/**
* @param Request $request
*/
public function toArray($request): array
{
return [
'id' => $this->id,
'label' => $this->label,
'token' => $this->token,
'url' => url('/e/'.$this->token),
'usage_limit' => $this->usage_limit,
'usage_count' => $this->usage_count,
'expires_at' => optional($this->expires_at)->toIso8601String(),
'revoked_at' => optional($this->revoked_at)->toIso8601String(),
'is_active' => $this->isActive(),
'created_at' => optional($this->created_at)->toIso8601String(),
'metadata' => $this->metadata ?? new \stdClass(),
];
}
}

View File

@@ -58,6 +58,11 @@ class Event extends Model
return $this->belongsTo(EventPackage::class);
}
public function joinTokens(): HasMany
{
return $this->hasMany(EventJoinToken::class);
}
public function hasActivePackage(): bool
{
return $this->eventPackage && $this->eventPackage->isActive();

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class EventJoinToken extends Model
{
use HasFactory;
protected $fillable = [
'event_id',
'token',
'label',
'usage_limit',
'usage_count',
'expires_at',
'revoked_at',
'created_by',
'metadata',
];
protected $casts = [
'metadata' => 'array',
'expires_at' => 'datetime',
'revoked_at' => 'datetime',
'usage_limit' => 'integer',
'usage_count' => 'integer',
];
public function event(): BelongsTo
{
return $this->belongsTo(Event::class);
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
public function isActive(): bool
{
if ($this->revoked_at !== null) {
return false;
}
if ($this->expires_at !== null && $this->expires_at->isPast()) {
return false;
}
if ($this->usage_limit !== null && $this->usage_count >= $this->usage_limit) {
return false;
}
return true;
}
}

View File

@@ -0,0 +1,85 @@
<?php
namespace App\Services;
use App\Models\Event;
use App\Models\EventJoinToken;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class EventJoinTokenService
{
public function createToken(Event $event, array $attributes = []): EventJoinToken
{
return DB::transaction(function () use ($event, $attributes) {
$tokenValue = $this->generateUniqueToken();
$payload = [
'event_id' => $event->id,
'token' => $tokenValue,
'label' => Arr::get($attributes, 'label'),
'usage_limit' => Arr::get($attributes, 'usage_limit'),
'metadata' => Arr::get($attributes, 'metadata', []),
];
if ($expiresAt = Arr::get($attributes, 'expires_at')) {
$payload['expires_at'] = $expiresAt instanceof Carbon
? $expiresAt
: Carbon::parse($expiresAt);
}
if ($createdBy = Arr::get($attributes, 'created_by')) {
$payload['created_by'] = $createdBy;
}
return EventJoinToken::create($payload);
});
}
public function revoke(EventJoinToken $joinToken, ?string $reason = null): EventJoinToken
{
$joinToken->revoked_at = now();
if ($reason) {
$metadata = $joinToken->metadata ?? [];
$metadata['revoked_reason'] = $reason;
$joinToken->metadata = $metadata;
}
$joinToken->save();
return $joinToken;
}
public function incrementUsage(EventJoinToken $joinToken): void
{
$joinToken->increment('usage_count');
}
public function findActiveToken(string $token): ?EventJoinToken
{
return EventJoinToken::query()
->where('token', $token)
->whereNull('revoked_at')
->where(function ($query) {
$query->whereNull('expires_at')
->orWhere('expires_at', '>', now());
})
->where(function ($query) {
$query->whereNull('usage_limit')
->orWhereColumn('usage_limit', '>', 'usage_count');
})
->first();
}
protected function generateUniqueToken(int $length = 48): string
{
do {
$token = Str::random($length);
} while (EventJoinToken::where('token', $token)->exists());
return $token;
}
}