attributes->get('tenant_id'); $event = Event::with([ 'tenant', 'eventPackage.package', 'eventPackages.package', 'storageAssignments.storageTarget', ])->where('slug', $eventSlug) ->where('tenant_id', $tenantId) ->firstOrFail(); $event->loadMissing(['tenant', 'eventPackage.package', 'eventPackages.package']); $tenant = $event->tenant; $eventPackage = $tenant ? $this->packageLimitEvaluator->resolveEventPackageForPhotoUpload($tenant, $event->id, $event) : null; $limitSummary = $eventPackage ? $this->packageLimitEvaluator->summarizeEventPackage($eventPackage) : null; $query = Photo::where('event_id', $event->id) ->with('event')->withCount('likes') ->orderBy('created_at', 'desc'); // Filters if ($request->has('status')) { $query->where('status', $request->status); } if ($request->has('user_id')) { $query->where('uploader_id', $request->user_id); } $perPage = $request->get('per_page', 20); $photos = $query->paginate($perPage); return PhotoResource::collection($photos)->additional([ 'limits' => $limitSummary, ]); } /** * Store a newly uploaded photo. */ public function store(PhotoStoreRequest $request, string $eventSlug): JsonResponse { $tenantId = $request->attributes->get('tenant_id'); $event = Event::where('slug', $eventSlug) ->where('tenant_id', $tenantId) ->firstOrFail(); $event->loadMissing(['tenant', 'eventPackage.package', 'eventPackages.package']); $tenant = $event->tenant; $eventPackage = $tenant ? $this->packageLimitEvaluator->resolveEventPackageForPhotoUpload($tenant, $event->id, $event) : null; if ($tenant) { $violation = $this->packageLimitEvaluator->assessPhotoUpload($tenant, $event->id, $event); if ($violation !== null) { return ApiError::response( $violation['code'], $violation['title'], $violation['message'], $violation['status'], $violation['meta'] ); } } $previousUsedPhotos = $eventPackage?->used_photos ?? 0; $validated = $request->validated(); $file = $request->file('photo'); if (! $file) { throw ValidationException::withMessages([ 'photo' => 'No photo file uploaded.', ]); } // Validate file type and size $allowedTypes = ['image/jpeg', 'image/png', 'image/webp']; if (! in_array($file->getMimeType(), $allowedTypes)) { throw ValidationException::withMessages([ 'photo' => 'Only JPEG, PNG, and WebP images are allowed.', ]); } if ($file->getSize() > 10 * 1024 * 1024) { // 10MB throw ValidationException::withMessages([ 'photo' => 'Photo size must be less than 10MB.', ]); } // Determine storage target $disk = $this->eventStorageManager->getHotDiskForEvent($event); // Generate unique filename $extension = $file->getClientOriginalExtension(); $filename = Str::uuid().'.'.$extension; $path = "events/{$eventSlug}/photos/{$filename}"; // Store original file Storage::disk($disk)->put($path, file_get_contents($file->getRealPath())); // Generate thumbnail $thumbnailPath = "events/{$eventSlug}/thumbnails/{$filename}"; $thumbnailRelative = ImageHelper::makeThumbnailOnDisk($disk, $path, $thumbnailPath, 400); if ($thumbnailRelative) { $thumbnailPath = $thumbnailRelative; } // Create photo record $photo = Photo::create([ 'event_id' => $event->id, 'filename' => $filename, 'original_name' => $file->getClientOriginalName(), 'mime_type' => $file->getMimeType(), 'size' => $file->getSize(), 'path' => $path, 'thumbnail_path' => $thumbnailPath, 'width' => null, // Filled below 'height' => null, 'status' => 'pending', // Requires moderation 'uploader_id' => null, 'ip_address' => $request->ip(), 'user_agent' => $request->userAgent(), ]); // Record primary asset metadata $checksum = hash_file('sha256', $file->getRealPath()); $asset = $this->eventStorageManager->recordAsset($event, $disk, $path, [ 'variant' => 'original', 'mime_type' => $file->getMimeType(), 'size_bytes' => $file->getSize(), 'checksum' => $checksum, 'status' => 'hot', 'processed_at' => now(), 'photo_id' => $photo->id, ]); if ($thumbnailRelative) { $this->eventStorageManager->recordAsset($event, $disk, $thumbnailRelative, [ 'variant' => 'thumbnail', 'mime_type' => 'image/jpeg', 'status' => 'hot', 'processed_at' => now(), 'photo_id' => $photo->id, 'size_bytes' => Storage::disk($disk)->exists($thumbnailRelative) ? Storage::disk($disk)->size($thumbnailRelative) : null, 'meta' => [ 'source_variant_id' => $asset->id, ], ]); } $photo->update(['media_asset_id' => $asset->id]); // Get image dimensions [$width, $height] = getimagesize($file->getRealPath()); $photo->update(['width' => $width, 'height' => $height]); ProcessPhotoSecurityScan::dispatch($photo->id); if ($eventPackage) { $eventPackage->increment('used_photos'); $eventPackage->refresh(); $this->packageUsageTracker->recordPhotoUsage($eventPackage, $previousUsedPhotos, 1); } $limitSummary = $eventPackage ? $this->packageLimitEvaluator->summarizeEventPackage($eventPackage) : null; $photo->load('event')->loadCount('likes'); return response()->json([ 'message' => 'Photo uploaded successfully. Awaiting moderation.', 'data' => new PhotoResource($photo), 'moderation_notice' => 'Your photo has been uploaded and will be reviewed shortly.', 'limits' => $limitSummary, ], 201); } /** * Display the specified photo. */ public function show(Request $request, string $eventSlug, Photo $photo): JsonResponse { $tenantId = $request->attributes->get('tenant_id'); $event = Event::where('slug', $eventSlug) ->where('tenant_id', $tenantId) ->firstOrFail(); if ($photo->event_id !== $event->id) { return ApiError::response( 'photo_not_found', 'Foto nicht gefunden', 'Das Foto gehört nicht zu diesem Event.', 404, ['photo_id' => $photo->id] ); } $photo->load('event')->loadCount('likes'); $photo->increment('view_count'); return response()->json([ 'data' => new PhotoResource($photo), ]); } /** * Update the specified photo (moderation or metadata). */ public function update(Request $request, string $eventSlug, Photo $photo): JsonResponse { $tenantId = $request->attributes->get('tenant_id'); $event = Event::where('slug', $eventSlug) ->where('tenant_id', $tenantId) ->firstOrFail(); if ($photo->event_id !== $event->id) { return ApiError::response( 'photo_not_found', 'Foto nicht gefunden', 'Das Foto gehört nicht zu diesem Event.', 404, ['photo_id' => $photo->id] ); } $validated = $request->validate([ 'status' => ['sometimes', 'in:pending,approved,rejected'], 'moderation_notes' => ['sometimes', 'required_if:status,rejected', 'string', 'max:1000'], 'caption' => ['sometimes', 'string', 'max:500'], 'alt_text' => ['sometimes', 'string', 'max:255'], ]); // Only tenant admins can moderate if (isset($validated['status']) && ! $this->tokenHasScope($request, 'tenant:write')) { return ApiError::response( 'insufficient_scope', 'Insufficient Scopes', 'You are not allowed to moderate photos for this event.', Response::HTTP_FORBIDDEN, ['required_scope' => 'tenant:write'] ); } $photo->update($validated); if ($validated['status'] ?? null === 'approved') { $photo->load('event')->loadCount('likes'); // Trigger event for new photo notification // event(new \App\Events\PhotoApproved($photo)); // Implement later } return response()->json([ 'message' => 'Photo updated successfully', 'data' => new PhotoResource($photo), ]); } /** * Remove the specified photo from storage. */ public function destroy(Request $request, string $eventSlug, Photo $photo): JsonResponse { $tenantId = $request->attributes->get('tenant_id'); $event = Event::where('slug', $eventSlug) ->where('tenant_id', $tenantId) ->firstOrFail(); if ($photo->event_id !== $event->id) { return ApiError::response( 'photo_not_found', 'Foto nicht gefunden', 'Das Foto gehört nicht zu diesem Event.', 404, ['photo_id' => $photo->id] ); } $assets = EventMediaAsset::where('photo_id', $photo->id)->get(); foreach ($assets as $asset) { try { Storage::disk($asset->disk)->delete($asset->path); } catch (\Throwable $e) { Log::warning('Failed to delete asset from storage', [ 'asset_id' => $asset->id, 'disk' => $asset->disk, 'path' => $asset->path, 'error' => $e->getMessage(), ]); } } // Ensure legacy paths are removed if assets missing if ($assets->isEmpty()) { $fallbackDisk = $this->eventStorageManager->getHotDiskForEvent($event); Storage::disk($fallbackDisk)->delete([$photo->path, $photo->thumbnail_path]); } $eventPackage = $event->eventPackage; $usageTracker = app(\App\Services\Packages\PackageUsageTracker::class); // Delete record and likes DB::transaction(function () use ($photo, $assets) { $photo->likes()->delete(); if ($assets->isNotEmpty()) { EventMediaAsset::whereIn('id', $assets->pluck('id'))->delete(); } $photo->delete(); }); if ($eventPackage && $eventPackage->package) { $previousUsed = (int) $eventPackage->used_photos; if ($previousUsed > 0) { $eventPackage->decrement('used_photos'); $eventPackage->refresh(); $usageTracker->recordPhotoUsage($eventPackage, $previousUsed, -1); } } return response()->json([ 'message' => 'Photo deleted successfully', ]); } /** * Bulk approve photos (admin only) */ public function feature(Request $request, string $eventSlug, Photo $photo): JsonResponse { $tenantId = $request->attributes->get('tenant_id'); $event = Event::where('slug', $eventSlug) ->where('tenant_id', $tenantId) ->firstOrFail(); if ($photo->event_id !== $event->id) { return ApiError::response( 'photo_not_found', 'Photo not found', 'The specified photo could not be located for this event.', Response::HTTP_NOT_FOUND, ['photo_id' => $photo->id, 'event_id' => $event->id] ); } $photo->update(['is_featured' => true]); $photo->refresh()->load('event')->loadCount('likes'); return response()->json(['message' => 'Photo marked as featured', 'data' => new PhotoResource($photo)]); } public function unfeature(Request $request, string $eventSlug, Photo $photo): JsonResponse { $tenantId = $request->attributes->get('tenant_id'); $event = Event::where('slug', $eventSlug) ->where('tenant_id', $tenantId) ->firstOrFail(); if ($photo->event_id !== $event->id) { return ApiError::response( 'photo_not_found', 'Photo not found', 'The specified photo could not be located for this event.', Response::HTTP_NOT_FOUND, ['photo_id' => $photo->id, 'event_id' => $event->id] ); } $photo->update(['is_featured' => false]); $photo->refresh()->load('event')->loadCount('likes'); return response()->json(['message' => 'Photo removed from featured', 'data' => new PhotoResource($photo)]); } public function bulkApprove(Request $request, string $eventSlug): JsonResponse { $tenantId = $request->attributes->get('tenant_id'); $event = Event::where('slug', $eventSlug) ->where('tenant_id', $tenantId) ->firstOrFail(); $request->validate([ 'photo_ids' => 'required|array', 'photo_ids.*' => 'exists:photos,id', 'moderation_notes' => 'nullable|string|max:1000', ]); $photoIds = $request->photo_ids; $approvedCount = Photo::whereIn('id', $photoIds) ->where('event_id', $event->id) ->where('status', 'pending') ->update([ 'status' => 'approved', 'moderation_notes' => $request->moderation_notes, 'moderated_at' => now(), 'moderated_by' => null, ]); // Load approved photos for response $photos = Photo::whereIn('id', $photoIds) ->where('event_id', $event->id) ->with('event')->withCount('likes') ->get(); // Trigger events foreach ($photos as $photo) { // event(new \App\Events\PhotoApproved($photo)); // Implement later } return response()->json([ 'message' => "{$approvedCount} photos approved successfully", 'approved_count' => $approvedCount, 'data' => PhotoResource::collection($photos), ]); } /** * Bulk reject photos (admin only) */ public function bulkReject(Request $request, string $eventSlug): JsonResponse { $tenantId = $request->attributes->get('tenant_id'); $event = Event::where('slug', $eventSlug) ->where('tenant_id', $tenantId) ->firstOrFail(); $request->validate([ 'photo_ids' => 'required|array', 'photo_ids.*' => 'exists:photos,id', 'moderation_notes' => 'required|string|max:1000', ]); $photoIds = $request->photo_ids; $rejectedCount = Photo::whereIn('id', $photoIds) ->where('event_id', $event->id) ->where('status', 'pending') ->update([ 'status' => 'rejected', 'moderation_notes' => $request->moderation_notes, 'moderated_at' => now(), 'moderated_by' => null, ]); // Optionally delete rejected photos from storage $rejectedPhotos = Photo::whereIn('id', $photoIds) ->where('event_id', $event->id) ->get(); foreach ($rejectedPhotos as $photo) { Storage::disk('public')->delete([ $photo->path, $photo->thumbnail_path, ]); } return response()->json([ 'message' => "{$rejectedCount} photos rejected and deleted", 'rejected_count' => $rejectedCount, ]); } /** * Get photos for moderation (admin only) */ public function forModeration(Request $request, string $eventSlug): AnonymousResourceCollection { $tenantId = $request->attributes->get('tenant_id'); $event = Event::where('slug', $eventSlug) ->where('tenant_id', $tenantId) ->firstOrFail(); $photos = Photo::where('event_id', $event->id) ->where('status', 'pending') ->with('event')->withCount('likes') ->orderBy('created_at', 'desc') ->paginate($request->get('per_page', 20)); return PhotoResource::collection($photos); } /** * Get upload statistics for event */ public function stats(Request $request, string $eventSlug): JsonResponse { $tenantId = $request->attributes->get('tenant_id'); $event = Event::where('slug', $eventSlug) ->where('tenant_id', $tenantId) ->firstOrFail(); $totalPhotos = Photo::where('event_id', $event->id)->count(); $pendingPhotos = Photo::where('event_id', $event->id)->where('status', 'pending')->count(); $approvedPhotos = Photo::where('event_id', $event->id)->where('status', 'approved')->count(); $totalLikes = DB::table('photo_likes')->whereIn('photo_id', Photo::where('event_id', $event->id)->pluck('id') )->count(); $totalStorage = Photo::where('event_id', $event->id)->sum('size'); $uniqueUploaders = Photo::where('event_id', $event->id) ->select('uploader_id') ->distinct() ->count('uploader_id'); $recentUploads = Photo::where('event_id', $event->id) ->where('created_at', '>=', now()->subDays(7)) ->count(); return response()->json([ 'event_id' => $event->id, 'total_photos' => $totalPhotos, 'pending_photos' => $pendingPhotos, 'approved_photos' => $approvedPhotos, 'total_likes' => $totalLikes, 'total_storage_bytes' => $totalStorage, 'total_storage_mb' => round($totalStorage / (1024 * 1024), 2), 'unique_uploaders' => $uniqueUploaders, 'recent_uploads_7d' => $recentUploads, 'storage_quota_remaining' => $event->tenant->storage_quota - $totalStorage, 'quota_percentage_used' => min(100, round(($totalStorage / $event->tenant->storage_quota) * 100, 1)), ]); } private function tokenHasScope(Request $request, string $scope): bool { $scopes = $request->user()->scopes ?? ($request->attributes->get('decoded_token')['scopes'] ?? []); if (! is_array($scopes)) { $scopes = array_values(array_filter(explode(' ', (string) $scopes))); } return in_array($scope, $scopes, true); } /** * Generate presigned S3 URL for direct upload (alternative to local storage) */ public function presignedUpload(Request $request, string $eventSlug): JsonResponse { $tenantId = $request->attributes->get('tenant_id'); $event = Event::where('slug', $eventSlug) ->where('tenant_id', $tenantId) ->firstOrFail(); $request->validate([ 'filename' => 'required|string|max:255', 'content_type' => 'required|string|in:image/jpeg,image/png,image/webp', ]); // Generate unique filename $extension = pathinfo($request->filename, PATHINFO_EXTENSION); $filename = Str::uuid().'.'.$extension; $path = "events/{$eventSlug}/pending/{$filename}"; // For local storage, return direct upload endpoint // For S3, use Storage::disk('s3')->temporaryUrl() or presigned URL $uploadUrl = url("/api/v1/tenant/events/{$eventSlug}/upload-direct"); $fields = [ 'event_id' => $event->id, 'filename' => $filename, 'original_name' => $request->filename, ]; return response()->json([ 'upload_url' => $uploadUrl, 'fields' => $fields, 'path' => $path, 'max_size' => 10 * 1024 * 1024, // 10MB 'allowed_types' => ['image/jpeg', 'image/png', 'image/webp'], ]); } /** * Direct upload endpoint for presigned uploads */ public function uploadDirect(Request $request, string $eventSlug): JsonResponse { $tenantId = $request->attributes->get('tenant_id'); $event = Event::where('slug', $eventSlug) ->where('tenant_id', $tenantId) ->firstOrFail(); $request->validate([ 'event_id' => 'required|exists:events,id', 'filename' => 'required|string', 'original_name' => 'required|string', 'photo' => 'required|image|mimes:jpeg,png,webp|max:10240', // 10MB ]); if ($request->event_id !== $event->id) { return ApiError::response( 'event_mismatch', 'Invalid Event', 'The provided event does not match the authenticated tenant event.', Response::HTTP_BAD_REQUEST, ['payload_event_id' => $request->event_id, 'expected_event_id' => $event->id] ); } $event->load('storageAssignments.storageTarget'); $disk = $this->eventStorageManager->getHotDiskForEvent($event); $file = $request->file('photo'); $filename = $request->filename; $path = "events/{$eventSlug}/photos/{$filename}"; // Store file Storage::disk($disk)->put($path, file_get_contents($file->getRealPath())); // Generate thumbnail $thumbnailPath = "events/{$eventSlug}/thumbnails/{$filename}"; $thumbnailRelative = ImageHelper::makeThumbnailOnDisk($disk, $path, $thumbnailPath, 400); if ($thumbnailRelative) { $thumbnailPath = $thumbnailRelative; } // Create photo record $photo = Photo::create([ 'event_id' => $event->id, 'filename' => $filename, 'original_name' => $request->original_name, 'mime_type' => $file->getMimeType(), 'size' => $file->getSize(), 'path' => $path, 'thumbnail_path' => $thumbnailPath, 'status' => 'pending', 'ip_address' => $request->ip(), 'user_agent' => $request->userAgent(), ]); [$width, $height] = getimagesize($file->getRealPath()); $photo->update(['width' => $width, 'height' => $height]); $checksum = hash_file('sha256', $file->getRealPath()); $asset = $this->eventStorageManager->recordAsset($event, $disk, $path, [ 'variant' => 'original', 'mime_type' => $file->getMimeType(), 'size_bytes' => $file->getSize(), 'checksum' => $checksum, 'status' => 'hot', 'processed_at' => now(), 'photo_id' => $photo->id, ]); if ($thumbnailRelative) { $this->eventStorageManager->recordAsset($event, $disk, $thumbnailRelative, [ 'variant' => 'thumbnail', 'mime_type' => 'image/jpeg', 'status' => 'hot', 'processed_at' => now(), 'photo_id' => $photo->id, 'size_bytes' => Storage::disk($disk)->exists($thumbnailRelative) ? Storage::disk($disk)->size($thumbnailRelative) : null, 'meta' => [ 'source_variant_id' => $asset->id, ], ]); } $photo->update(['media_asset_id' => $asset->id]); return response()->json([ 'message' => 'Upload successful. Awaiting moderation.', 'photo_id' => $photo->id, 'status' => 'pending', ], 201); } }