attributes->get('tenant_id'); if (! $tenantId) { throw ValidationException::withMessages([ 'tenant_id' => 'Tenant ID not found in request context.', ]); } $query = Event::where('tenant_id', $tenantId) ->with([ 'eventType', 'eventPackages.package', 'eventPackage.package', ]) ->withCount([ 'photos', 'photos as pending_photos_count' => fn ($photoQuery) => $photoQuery->where('status', 'pending'), 'tasks as tasks_count', 'joinTokens as total_join_tokens_count', 'joinTokens as active_join_tokens_count' => fn ($tokenQuery) => $tokenQuery ->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'); }), ]) ->withSum('photos as likes_sum', 'likes_count') ->orderBy('created_at', 'desc'); if ($request->has('status')) { $query->where('status', $request->status); } if ($request->has('type_id')) { $query->where('event_type_id', $request->type_id); } $events = $query->paginate($request->get('per_page', 15)); return EventResource::collection($events); } public function store(EventStoreRequest $request): JsonResponse { TenantMemberPermissions::ensureTenantPermission($request, 'events:manage'); $tenant = $request->attributes->get('tenant'); if (! $tenant instanceof Tenant) { $tenantId = $request->attributes->get('tenant_id'); $tenant = Tenant::findOrFail($tenantId); } $actor = $request->user(); $isSuperAdmin = $actor instanceof User && $actor->isSuperAdmin(); // Package check is now handled by middleware $validated = $request->validated(); $tenantId = $tenant->id; $requestedPackageId = $isSuperAdmin ? $request->integer('package_id') : null; unset($validated['package_id']); $requestedServiceSlug = $request->input('service_package_slug'); $requestedServiceSlug = is_string($requestedServiceSlug) && $requestedServiceSlug !== '' ? $requestedServiceSlug : null; unset($validated['service_package_slug']); $tenantPackage = $tenant->tenantPackages() ->with('package') ->where('active', true) ->orderByDesc('purchased_at') ->first(); $package = null; if ($requestedPackageId) { $package = Package::query()->find($requestedPackageId); } if (! $package && $isSuperAdmin) { $package = $this->resolveOwnerPackage(); } $billingTenantPackage = null; if (! $package) { $billingTenantPackage = $requestedServiceSlug ? $tenant->getActiveResellerPackageFor($requestedServiceSlug) : $tenant->getActiveResellerPackage(); if ($billingTenantPackage && $billingTenantPackage->package) { $package = $billingTenantPackage->package; $requestedServiceSlug = $requestedServiceSlug ?: $package->included_package_slug; } } if (! $package && $tenantPackage) { $package = $tenantPackage->package ?? Package::query()->find($tenantPackage->package_id); } if (! $package) { throw ValidationException::withMessages([ 'package_id' => __('Aktuell ist kein aktives Paket verfügbar. Bitte buche zunächst ein Paket.'), ]); } $billingIsReseller = $package->isReseller(); $eventServicePackage = $billingIsReseller ? $this->resolveResellerEventPackageForSlug($requestedServiceSlug ?: $package->included_package_slug) : $package; $requiresWaiver = $package->isEndcustomer(); $latestPurchase = $requiresWaiver ? $this->resolveLatestPackagePurchase($tenant, $package) : null; $existingWaiver = $latestPurchase ? data_get($latestPurchase->metadata, 'consents.digital_content_waiver_at') : null; $needsWaiver = ! $isSuperAdmin && $requiresWaiver && ! $existingWaiver; if ($needsWaiver && ! $request->boolean('accepted_waiver')) { throw ValidationException::withMessages([ 'accepted_waiver' => 'Ein sofortiger Beginn der digitalen Dienstleistung erfordert Ihre ausdrückliche Zustimmung.', ]); } $eventData = array_merge($validated, [ 'tenant_id' => $tenantId, 'status' => $validated['status'] ?? 'draft', 'slug' => $this->generateUniqueSlug($validated['name'], $tenantId), ]); if (isset($eventData['event_date'])) { $eventData['date'] = $eventData['event_date']; unset($eventData['event_date']); } $settings = $eventData['settings'] ?? []; foreach (['public_url', 'custom_domain', 'theme_color'] as $key) { if (array_key_exists($key, $eventData)) { $settings[$key] = $eventData[$key]; unset($eventData[$key]); } } if (isset($eventData['features'])) { $settings['features'] = $eventData['features']; unset($eventData['features']); } $settings['branding_allowed'] = $eventServicePackage->branding_allowed !== false; $settings['watermark_allowed'] = $eventServicePackage->watermark_allowed !== false; $eventData['settings'] = $settings; foreach (['password', 'password_confirmation', 'password_protected', 'logo_image', 'cover_image'] as $unused) { unset($eventData[$unused]); } $allowed = [ 'tenant_id', 'name', 'description', 'date', 'slug', 'location', 'max_participants', 'settings', 'event_type_id', 'is_active', 'join_link_enabled', 'photo_upload_enabled', 'task_checklist_enabled', 'default_locale', 'status', ]; $eventData = Arr::only($eventData, $allowed); $event = DB::transaction(function () use ($tenant, $eventData, $eventServicePackage, $billingIsReseller, $isSuperAdmin) { $event = Event::create($eventData); EventPackage::create([ 'event_id' => $event->id, 'package_id' => $eventServicePackage->id, 'purchased_price' => $billingIsReseller ? 0 : $eventServicePackage->price, 'purchased_at' => now(), 'gallery_expires_at' => $eventServicePackage->gallery_days ? now()->addDays($eventServicePackage->gallery_days) : null, ]); if ($billingIsReseller && ! $isSuperAdmin) { $note = sprintf('Event #%d created (%s)', $event->id, $event->name); if (! $tenant->consumeEventAllowanceFor($eventServicePackage->slug, 1, 'event.create', $note)) { throw new HttpException(402, 'Insufficient package allowance.'); } } return $event; }); if ($needsWaiver) { $this->recordEventStartWaiver($tenant, $package, $latestPurchase); } $tenant->refresh(); $event->load(['eventType', 'tenant', 'eventPackages.package', 'eventPackages.addons']); return response()->json([ 'message' => 'Event created successfully', 'data' => new EventResource($event), 'package' => $event->eventPackage ? $event->eventPackage->package->name : 'None', 'remaining_events' => $tenant->activeResellerPackage ? $tenant->activeResellerPackage->remaining_events : 0, ], 201); } private function resolveResellerDefaultEventPackage(): Package { return $this->resolveResellerEventPackageForSlug('standard'); } private function resolveResellerEventPackageForSlug(?string $slug): Package { if (is_string($slug) && $slug !== '') { $match = Package::query() ->where('type', 'endcustomer') ->where('slug', $slug) ->first(); if ($match) { return $match; } } $default = Package::query() ->where('type', 'endcustomer') ->where('slug', 'standard') ->first(); if ($default) { return $default; } $fallback = Package::query() ->where('type', 'endcustomer') ->orderBy('price') ->first(); if (! $fallback) { throw ValidationException::withMessages([ 'package_id' => __('Aktuell ist kein Endkunden-Paket verfügbar. Bitte kontaktiere den Support.'), ]); } return $fallback; } private function resolveLatestPackagePurchase(Tenant $tenant, Package $package): ?PackagePurchase { return PackagePurchase::query() ->where('tenant_id', $tenant->id) ->where('package_id', $package->id) ->orderByDesc('purchased_at') ->orderByDesc('id') ->first(); } private function resolveOwnerPackage(): ?Package { $ownerPackage = Package::query() ->where('slug', 'pro') ->first(); return $ownerPackage ?? Package::query()->find(3); } private function recordEventStartWaiver(Tenant $tenant, Package $package, ?PackagePurchase $purchase): void { $timestamp = now(); $legalVersion = config('app.legal_version', $timestamp->toDateString()); if ($purchase) { $metadata = $purchase->metadata ?? []; $consents = is_array($metadata['consents'] ?? null) ? $metadata['consents'] : []; $consents['digital_content_waiver_at'] = $timestamp->toIso8601String(); $consents['legal_version'] = $consents['legal_version'] ?? $legalVersion; $metadata['consents'] = $consents; $purchase->metadata = $metadata; $purchase->save(); } $session = CheckoutSession::query() ->where('tenant_id', $tenant->id) ->where('package_id', $package->id) ->where('status', CheckoutSession::STATUS_COMPLETED) ->orderByDesc('completed_at') ->first(); if ($session && ! $session->digital_content_waiver_at) { $session->digital_content_waiver_at = $timestamp; $session->legal_version = $session->legal_version ?? $legalVersion; $session->save(); } } public function show(Request $request, Event $event): JsonResponse { $tenantId = $request->attributes->get('tenant_id'); if ($event->tenant_id !== $tenantId) { return ApiError::response( 'event_not_found', 'Event not accessible', 'Das Event konnte nicht gefunden werden.', 404, ['event_slug' => $event->slug ?? null] ); } $event->load([ 'eventType', 'photos' => fn ($query) => $query->with('likes')->latest(), 'tasks', 'tenant' => fn ($query) => $query->select('id', 'name'), 'eventPackages' => fn ($query) => $query ->with(['package', 'addons']) ->orderByDesc('purchased_at') ->orderByDesc('created_at'), ]); return response()->json([ 'data' => new EventResource($event), ]); } public function update(EventStoreRequest $request, Event $event): JsonResponse { $tenantId = $request->attributes->get('tenant_id'); $event->loadMissing('eventPackage.package'); if ($event->tenant_id !== $tenantId) { return ApiError::response( 'event_not_found', 'Event not accessible', 'Das Event konnte nicht gefunden werden.', 404, ['event_slug' => $event->slug ?? null] ); } TenantMemberPermissions::ensureEventPermission($request, $event, 'events:manage'); $validated = $request->validated(); if (isset($validated['event_date'])) { $validated['date'] = $validated['event_date']; unset($validated['event_date']); } if ($validated['name'] !== $event->name) { $validated['slug'] = $this->generateUniqueSlug($validated['name'], $tenantId, $event->id); } foreach (['password', 'password_confirmation', 'password_protected'] as $unused) { unset($validated[$unused]); } $package = $event->eventPackage?->package; $brandingAllowed = optional($package)->branding_allowed !== false; $watermarkAllowed = optional($package)->watermark_allowed !== false; if (isset($validated['settings']) && is_array($validated['settings'])) { $validated['settings'] = array_merge($event->settings ?? [], $validated['settings']); } else { $validated['settings'] = $event->settings ?? []; } $validated['settings']['branding_allowed'] = $brandingAllowed; $validated['settings']['watermark_allowed'] = $watermarkAllowed; $settings = $validated['settings']; $branding = Arr::get($settings, 'branding', []); $watermark = Arr::get($settings, 'watermark', []); $existingWatermark = is_array($watermark) ? $watermark : []; if (is_array($branding)) { $settings['branding'] = $this->normalizeBrandingSettings($branding, $event, $brandingAllowed); } if (is_array($watermark)) { $mode = $watermark['mode'] ?? 'base'; $policy = $watermarkAllowed ? 'basic' : 'none'; if (! $watermarkAllowed) { $mode = 'off'; } elseif (! $brandingAllowed) { $mode = 'base'; } elseif ($mode === 'off' && $policy === 'basic') { $mode = 'base'; } $assetPath = $watermark['asset'] ?? null; $assetDataUrl = $watermark['asset_data_url'] ?? null; if (! $watermarkAllowed) { $assetPath = null; } if ($assetDataUrl && $mode === 'custom' && $brandingAllowed) { if (! preg_match('/^data:image\\/(png|webp|jpe?g);base64,(.+)$/i', $assetDataUrl, $matches)) { throw ValidationException::withMessages([ 'settings.watermark.asset_data_url' => __('Ungültiges Wasserzeichen-Bild.'), ]); } $decoded = base64_decode($matches[2], true); if ($decoded === false) { throw ValidationException::withMessages([ 'settings.watermark.asset_data_url' => __('Wasserzeichen konnte nicht gelesen werden.'), ]); } if (strlen($decoded) > 3 * 1024 * 1024) { // 3 MB throw ValidationException::withMessages([ 'settings.watermark.asset_data_url' => __('Wasserzeichen ist zu groß (max. 3 MB).'), ]); } $extension = str_starts_with(strtolower($matches[1]), 'jp') ? 'jpg' : strtolower($matches[1]); $path = sprintf('branding/watermarks/event-%s.%s', $event->id, $extension); Storage::disk('public')->put($path, $decoded); $assetPath = $path; } $position = $watermark['position'] ?? 'bottom-right'; $validPositions = [ 'top-left', 'top-center', 'top-right', 'middle-left', 'center', 'middle-right', 'bottom-left', 'bottom-center', 'bottom-right', ]; if (! in_array($position, $validPositions, true)) { $position = 'bottom-right'; } $settings['watermark'] = [ 'mode' => $mode, 'asset' => $assetPath, 'position' => $position, 'opacity' => isset($watermark['opacity']) ? (float) $watermark['opacity'] : ($existingWatermark['opacity'] ?? null), 'scale' => isset($watermark['scale']) ? (float) $watermark['scale'] : ($existingWatermark['scale'] ?? null), 'padding' => isset($watermark['padding']) ? (int) $watermark['padding'] : ($existingWatermark['padding'] ?? null), 'offset_x' => isset($watermark['offset_x']) ? (int) $watermark['offset_x'] : ($existingWatermark['offset_x'] ?? 0), 'offset_y' => isset($watermark['offset_y']) ? (int) $watermark['offset_y'] : ($existingWatermark['offset_y'] ?? 0), ]; } if (array_key_exists('watermark_serve_originals', $settings)) { $settings['watermark_serve_originals'] = (bool) $settings['watermark_serve_originals']; } $validated['settings'] = $settings; $event->update($validated); $event->load(['eventType', 'tenant']); return response()->json([ 'message' => 'Event updated successfully', 'data' => new EventResource($event), ]); } /** * @param array $branding * @return array */ private function normalizeBrandingSettings(array $branding, Event $event, bool $brandingAllowed): array { $logoDataUrl = $branding['logo_data_url'] ?? null; if (! $brandingAllowed) { unset($branding['logo_data_url']); return $branding; } if (! is_string($logoDataUrl) || trim($logoDataUrl) === '') { unset($branding['logo_data_url']); return $branding; } if (! preg_match('/^data:image\\/(png|webp|jpe?g);base64,(.+)$/i', $logoDataUrl, $matches)) { throw ValidationException::withMessages([ 'settings.branding.logo_data_url' => __('Ungültiges Branding-Logo.'), ]); } $decoded = base64_decode($matches[2], true); if ($decoded === false) { throw ValidationException::withMessages([ 'settings.branding.logo_data_url' => __('Branding-Logo konnte nicht gelesen werden.'), ]); } if (strlen($decoded) > 1024 * 1024) { // 1 MB throw ValidationException::withMessages([ 'settings.branding.logo_data_url' => __('Branding-Logo ist zu groß (max. 1 MB).'), ]); } $extension = str_starts_with(strtolower($matches[1]), 'jp') ? 'jpg' : strtolower($matches[1]); $path = sprintf('branding/logos/event-%s.%s', $event->id, $extension); Storage::disk('public')->put($path, $decoded); $branding['logo_url'] = $path; $branding['logo_mode'] = 'upload'; $branding['logo_value'] = $path; $logo = $branding['logo'] ?? []; if (! is_array($logo)) { $logo = []; } $logo['mode'] = 'upload'; $logo['value'] = $path; $branding['logo'] = $logo; unset($branding['logo_data_url']); return $branding; } public function destroy(Request $request, Event $event): JsonResponse { $tenantId = $request->attributes->get('tenant_id'); if ($event->tenant_id !== $tenantId) { return ApiError::response( 'event_not_found', 'Event not accessible', 'Das Event konnte nicht gefunden werden.', 404, ['event_slug' => $event->slug ?? null] ); } TenantMemberPermissions::ensureEventPermission($request, $event, 'events:manage'); $event->delete(); return response()->json([ 'message' => 'Event deleted successfully', ]); } public function stats(Request $request, Event $event): JsonResponse { $tenantId = $request->attributes->get('tenant_id'); if ($event->tenant_id !== $tenantId) { return ApiError::response( 'event_not_found', 'Event not accessible', 'Das Event konnte nicht gefunden werden.', 404, ['event_slug' => $event->slug ?? null] ); } $totalPhotos = Photo::where('event_id', $event->id)->count(); $featuredPhotos = Photo::where('event_id', $event->id)->where('is_featured', true)->count(); $likes = Photo::where('event_id', $event->id)->sum('likes_count'); $recentUploads = Photo::where('event_id', $event->id) ->where('created_at', '>=', now()->subDays(7)) ->count(); return response()->json([ 'total' => $totalPhotos, 'featured' => $featuredPhotos, 'likes' => (int) $likes, 'recent_uploads' => $recentUploads, 'status' => $event->status, 'is_active' => (bool) $event->is_active, ]); } public function toolkit(Request $request, Event $event): JsonResponse { $tenantId = $request->attributes->get('tenant_id'); if ($event->tenant_id !== $tenantId) { return ApiError::response( 'event_not_found', 'Event not accessible', 'Das Event konnte nicht gefunden werden.', 404, ['event_slug' => $event->slug ?? null] ); } $event->load(['eventType', 'eventPackage.package']); $photoQuery = Photo::query()->where('event_id', $event->id); $pendingPhotos = (clone $photoQuery) ->where('status', 'pending') ->latest('created_at') ->take(6) ->get(); $recentUploads = (clone $photoQuery) ->where('status', 'approved') ->latest('created_at') ->take(8) ->get(); $pendingCount = (clone $photoQuery)->where('status', 'pending')->count(); $uploads24h = (clone $photoQuery)->where('created_at', '>=', now()->subDay())->count(); $totalUploads = (clone $photoQuery)->count(); $tasks = $event->tasks() ->orderBy('tasks.sort_order') ->orderBy('tasks.created_at') ->get(['tasks.id', 'tasks.title', 'tasks.description', 'tasks.priority', 'tasks.is_completed']); $taskSummary = [ 'total' => $tasks->count(), 'completed' => $tasks->where('is_completed', true)->count(), ]; $taskSummary['pending'] = max(0, $taskSummary['total'] - $taskSummary['completed']); $translate = static function ($value, ?string $fallback = '') { if (is_array($value)) { $locale = app()->getLocale(); $candidates = array_filter([ $locale, $locale && str_contains($locale, '-') ? explode('-', $locale)[0] : null, 'de', 'en', ]); foreach ($candidates as $candidate) { if ($candidate && isset($value[$candidate]) && $value[$candidate] !== '') { return $value[$candidate]; } } $first = reset($value); return $first !== false ? $first : $fallback; } if (is_string($value) && $value !== '') { return $value; } return $fallback; }; $taskPreview = $tasks ->take(6) ->map(fn ($task) => [ 'id' => $task->id, 'title' => $translate($task->title, 'Task'), 'description' => $translate($task->description, null), 'is_completed' => (bool) $task->is_completed, 'priority' => $task->priority, ]) ->values(); $joinTokenQuery = $event->joinTokens(); $totalInvites = (clone $joinTokenQuery)->count(); $activeInvites = (clone $joinTokenQuery) ->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'); }) ->count(); $recentInvites = (clone $joinTokenQuery) ->orderByDesc('created_at') ->take(3) ->get(); $notificationQuery = GuestNotification::query()->where('event_id', $event->id); $notificationTotal = (clone $notificationQuery)->count(); $notificationTypeCounts = (clone $notificationQuery) ->select('type', DB::raw('COUNT(*) as total')) ->groupBy('type') ->pluck('total', 'type') ->map(fn ($value) => (int) $value) ->toArray(); $lastNotificationAt = $notificationTotal > 0 ? (clone $notificationQuery)->latest('created_at')->value('created_at') : null; $lastBroadcast = (clone $notificationQuery) ->where('type', GuestNotificationType::BROADCAST->value) ->latest('created_at') ->first(['id', 'title', 'created_at']); $recentNotifications = (clone $notificationQuery) ->latest('created_at') ->limit(5) ->get(['id', 'title', 'type', 'status', 'audience_scope', 'created_at']); $notificationsPayload = [ 'summary' => [ 'total' => $notificationTotal, 'last_sent_at' => $lastNotificationAt ? $lastNotificationAt->toAtomString() : null, 'by_type' => $notificationTypeCounts, 'broadcasts' => [ 'total' => $notificationTypeCounts[GuestNotificationType::BROADCAST->value] ?? 0, 'last_title' => $lastBroadcast?->title, 'last_sent_at' => $lastBroadcast?->created_at?->toAtomString(), ], ], 'recent' => $recentNotifications->map(fn (GuestNotification $notification) => [ 'id' => $notification->id, 'title' => $notification->title, 'type' => $notification->type->value, 'status' => $notification->status->value, 'audience_scope' => $notification->audience_scope->value, 'created_at' => $notification->created_at?->toAtomString(), ])->all(), ]; $alerts = []; if (($event->settings['engagement_mode'] ?? 'tasks') !== 'photo_only' && $taskSummary['total'] === 0) { $alerts[] = 'no_tasks'; } if ($activeInvites === 0) { $alerts[] = 'no_invites'; } if ($pendingCount > 0) { $alerts[] = 'pending_photos'; } return response()->json([ 'event' => new EventResource($event), 'metrics' => [ 'uploads_total' => $totalUploads, 'uploads_24h' => $uploads24h, 'pending_photos' => $pendingCount, 'active_invites' => $activeInvites, 'engagement_mode' => $event->settings['engagement_mode'] ?? 'tasks', ], 'tasks' => [ 'summary' => $taskSummary, 'items' => $taskPreview, ], 'photos' => [ 'pending' => PhotoResource::collection($pendingPhotos)->resolve($request), 'recent' => PhotoResource::collection($recentUploads)->resolve($request), ], 'invites' => [ 'summary' => [ 'total' => $totalInvites, 'active' => $activeInvites, ], 'items' => EventJoinTokenResource::collection($recentInvites)->resolve($request), ], 'notifications' => $notificationsPayload, 'alerts' => $alerts, ]); } public function toggle(Request $request, Event $event): JsonResponse { $tenantId = $request->attributes->get('tenant_id'); if ($event->tenant_id !== $tenantId) { return ApiError::response( 'event_not_found', 'Event not accessible', 'Das Event konnte nicht gefunden werden.', 404, ['event_slug' => $event->slug ?? null] ); } $activate = ! (bool) $event->is_active; $event->is_active = $activate; if ($activate) { $event->status = 'published'; } elseif ($event->status === 'published') { $event->status = 'draft'; } $event->save(); $event->refresh()->load(['eventType', 'tenant']); return response()->json([ 'message' => $activate ? 'Event activated' : 'Event deactivated', 'data' => new EventResource($event), 'is_active' => (bool) $event->is_active, ]); } public function createInvite(Request $request, Event $event): JsonResponse { $tenantId = $request->attributes->get('tenant_id'); if ($event->tenant_id !== $tenantId) { return ApiError::response( 'event_not_found', 'Event not accessible', 'Das Event konnte nicht gefunden werden.', 404, ['event_slug' => $event->slug ?? null] ); } $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' => 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 { $tenantId = $request->attributes->get('tenant_id'); $validated = $request->validate([ 'event_ids' => 'required|array', 'event_ids.*' => 'exists:events,id', 'status' => 'required|in:draft,published,archived', ]); $updatedCount = Event::whereIn('id', $validated['event_ids']) ->where('tenant_id', $tenantId) ->update(['status' => $validated['status']]); return response()->json([ 'message' => "{$updatedCount} events updated successfully", 'updated_count' => $updatedCount, ]); } private function generateUniqueSlug(string $name, int $tenantId, ?int $excludeId = null): string { $slug = Str::slug($name); $originalSlug = $slug; $counter = 1; while (Event::where('slug', $slug) ->where('tenant_id', $tenantId) ->when($excludeId, fn ($query) => $query->where('id', '!=', $excludeId)) ->exists()) { $slug = $originalSlug.'-'.$counter; $counter++; } return $slug; } public function search(Request $request): AnonymousResourceCollection { $tenantId = $request->attributes->get('tenant_id'); $query = $request->get('q', ''); if (strlen($query) < 2) { return EventResource::collection(collect([])); } $events = Event::where('tenant_id', $tenantId) ->where(function ($q) use ($query) { $q->where('name', 'like', "%{$query}%") ->orWhere('description', 'like', "%{$query}%"); }) ->with('eventType') ->limit(10) ->get(); return EventResource::collection($events); } }