Implement multi-tenancy support with OAuth2 authentication for tenant admins, Stripe integration for event purchases and credits ledger, new Filament resources for event purchases, updated API routes and middleware for tenant isolation and token guarding, added factories/seeders/migrations for new models (Tenant, EventPurchase, OAuth entities, etc.), enhanced tests, and documentation updates. Removed outdated DemoAchievementsSeeder.
This commit is contained in:
470
app/Http/Controllers/Api/Tenant/PhotoController.php
Normal file
470
app/Http/Controllers/Api/Tenant/PhotoController.php
Normal file
@@ -0,0 +1,470 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Tenant;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Tenant\PhotoStoreRequest;
|
||||
use App\Http\Resources\Tenant\PhotoResource;
|
||||
use App\Models\Event;
|
||||
use App\Models\Photo;
|
||||
use App\Support\ImageHelper;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class PhotoController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of the event's photos.
|
||||
*/
|
||||
public function index(Request $request, string $eventSlug): AnonymousResourceCollection
|
||||
{
|
||||
$tenantId = $request->attributes->get('tenant_id');
|
||||
$event = Event::where('slug', $eventSlug)
|
||||
->where('tenant_id', $tenantId)
|
||||
->firstOrFail();
|
||||
|
||||
$query = Photo::where('event_id', $event->id)
|
||||
->with(['likes', 'uploader'])
|
||||
->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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
|
||||
$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.',
|
||||
]);
|
||||
}
|
||||
|
||||
// Generate unique filename
|
||||
$extension = $file->getClientOriginalExtension();
|
||||
$filename = Str::uuid() . '.' . $extension;
|
||||
$path = "events/{$eventSlug}/photos/{$filename}";
|
||||
|
||||
// Store original file
|
||||
Storage::disk('public')->put($path, file_get_contents($file->getRealPath()));
|
||||
|
||||
// Generate thumbnail
|
||||
$thumbnailPath = "events/{$eventSlug}/thumbnails/{$filename}";
|
||||
$thumbnailRelative = ImageHelper::makeThumbnailOnDisk('public', $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, // To be filled by image processing
|
||||
'height' => null,
|
||||
'status' => 'pending', // Requires moderation
|
||||
'uploader_id' => $request->user()->id ?? null,
|
||||
'ip_address' => $request->ip(),
|
||||
'user_agent' => $request->userAgent(),
|
||||
]);
|
||||
|
||||
// Get image dimensions
|
||||
list($width, $height) = getimagesize($file->getRealPath());
|
||||
$photo->update(['width' => $width, 'height' => $height]);
|
||||
|
||||
$photo->load(['event', 'uploader']);
|
||||
|
||||
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.',
|
||||
], 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 response()->json(['error' => 'Photo not found'], 404);
|
||||
}
|
||||
|
||||
$photo->load(['event', 'uploader', '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 response()->json(['error' => 'Photo not found'], 404);
|
||||
}
|
||||
|
||||
$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']) && $request->user()->role !== 'admin') {
|
||||
return response()->json(['error' => 'Insufficient permissions for moderation'], 403);
|
||||
}
|
||||
|
||||
$photo->update($validated);
|
||||
|
||||
if ($validated['status'] ?? null === 'approved') {
|
||||
$photo->load(['event', 'uploader']);
|
||||
// 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 response()->json(['error' => 'Photo not found'], 404);
|
||||
}
|
||||
|
||||
// Delete from storage
|
||||
Storage::disk('public')->delete([
|
||||
$photo->path,
|
||||
$photo->thumbnail_path,
|
||||
]);
|
||||
|
||||
// Delete record and likes
|
||||
DB::transaction(function () use ($photo) {
|
||||
$photo->likes()->delete();
|
||||
$photo->delete();
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Photo deleted successfully',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk approve photos (admin only)
|
||||
*/
|
||||
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' => $request->user()->id,
|
||||
]);
|
||||
|
||||
// Load approved photos for response
|
||||
$photos = Photo::whereIn('id', $photoIds)
|
||||
->where('event_id', $event->id)
|
||||
->with(['uploader', 'event'])
|
||||
->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' => $request->user()->id,
|
||||
]);
|
||||
|
||||
// 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(['uploader', 'event'])
|
||||
->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)),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 response()->json(['error' => 'Invalid event ID'], 400);
|
||||
}
|
||||
|
||||
$file = $request->file('photo');
|
||||
$filename = $request->filename;
|
||||
$path = "events/{$eventSlug}/photos/{$filename}";
|
||||
|
||||
// Store file
|
||||
Storage::disk('public')->put($path, file_get_contents($file->getRealPath()));
|
||||
|
||||
// Generate thumbnail
|
||||
$thumbnailPath = "events/{$eventSlug}/thumbnails/{$filename}";
|
||||
$thumbnailRelative = ImageHelper::makeThumbnailOnDisk('public', $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(),
|
||||
]);
|
||||
|
||||
// Get dimensions
|
||||
list($width, $height) = getimagesize($file->getRealPath());
|
||||
$photo->update(['width' => $width, 'height' => $height]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Upload successful. Awaiting moderation.',
|
||||
'photo_id' => $photo->id,
|
||||
'status' => 'pending',
|
||||
], 201);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user