944 lines
34 KiB
PHP
944 lines
34 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Api\Tenant;
|
|
|
|
use App\Enums\GuestNotificationType;
|
|
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\Http\Resources\Tenant\PhotoResource;
|
|
use App\Models\CheckoutSession;
|
|
use App\Models\Event;
|
|
use App\Models\EventPackage;
|
|
use App\Models\GuestNotification;
|
|
use App\Models\Package;
|
|
use App\Models\PackagePurchase;
|
|
use App\Models\Photo;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use App\Services\EventJoinTokenService;
|
|
use App\Support\ApiError;
|
|
use App\Support\TenantMemberPermissions;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
|
use Illuminate\Support\Arr;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Str;
|
|
use Illuminate\Validation\ValidationException;
|
|
use Symfony\Component\HttpKernel\Exception\HttpException;
|
|
|
|
class EventController extends Controller
|
|
{
|
|
public function __construct(private readonly EventJoinTokenService $joinTokenService) {}
|
|
|
|
public function index(Request $request): AnonymousResourceCollection
|
|
{
|
|
$tenantId = $request->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<string, mixed> $branding
|
|
* @return array<string, mixed>
|
|
*/
|
|
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);
|
|
}
|
|
}
|