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:
224
app/Http/Controllers/Api/Tenant/EventController.php
Normal file
224
app/Http/Controllers/Api/Tenant/EventController.php
Normal file
@@ -0,0 +1,224 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Tenant;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Tenant\EventStoreRequest;
|
||||
use Illuminate\Support\Str;
|
||||
use App\Http\Resources\Tenant\EventResource;
|
||||
use App\Models\Event;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class EventController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of the tenant's events.
|
||||
*/
|
||||
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', 'photos'])
|
||||
->orderBy('created_at', 'desc');
|
||||
|
||||
// Apply filters
|
||||
if ($request->has('status')) {
|
||||
$query->where('status', $request->status);
|
||||
}
|
||||
|
||||
if ($request->has('type_id')) {
|
||||
$query->where('event_type_id', $request->type_id);
|
||||
}
|
||||
|
||||
// Pagination
|
||||
$perPage = $request->get('per_page', 15);
|
||||
$events = $query->paginate($perPage);
|
||||
|
||||
return EventResource::collection($events);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created event in storage.
|
||||
*/
|
||||
public function store(EventStoreRequest $request): JsonResponse
|
||||
{
|
||||
$tenantId = $request->attributes->get('tenant_id');
|
||||
|
||||
// Check credits balance
|
||||
$tenant = Tenant::findOrFail($tenantId);
|
||||
if ($tenant->event_credits_balance <= 0) {
|
||||
return response()->json([
|
||||
'error' => 'Insufficient event credits. Please purchase more credits.',
|
||||
], 402);
|
||||
}
|
||||
|
||||
$validated = $request->validated();
|
||||
|
||||
$event = Event::create(array_merge($validated, [
|
||||
'tenant_id' => $tenantId,
|
||||
'status' => 'draft', // Default status
|
||||
'slug' => $this->generateUniqueSlug($validated['name'], $tenantId),
|
||||
]));
|
||||
|
||||
// Decrement credits
|
||||
$tenant->decrement('event_credits_balance', 1);
|
||||
|
||||
$event->load(['eventType', 'tenant']);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Event created successfully',
|
||||
'data' => new EventResource($event),
|
||||
], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the specified event.
|
||||
*/
|
||||
public function show(Request $request, Event $event): JsonResponse
|
||||
{
|
||||
$tenantId = $request->attributes->get('tenant_id');
|
||||
|
||||
// Ensure event belongs to tenant
|
||||
if ($event->tenant_id !== $tenantId) {
|
||||
return response()->json(['error' => 'Event not found'], 404);
|
||||
}
|
||||
|
||||
$event->load([
|
||||
'eventType',
|
||||
'photos' => fn ($query) => $query->with('likes')->latest(),
|
||||
'tasks',
|
||||
'tenant' => fn ($query) => $query->select('id', 'name', 'event_credits_balance')
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'data' => new EventResource($event),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified event in storage.
|
||||
*/
|
||||
public function update(EventStoreRequest $request, Event $event): JsonResponse
|
||||
{
|
||||
$tenantId = $request->attributes->get('tenant_id');
|
||||
|
||||
// Ensure event belongs to tenant
|
||||
if ($event->tenant_id !== $tenantId) {
|
||||
return response()->json(['error' => 'Event not found'], 404);
|
||||
}
|
||||
|
||||
$validated = $request->validated();
|
||||
|
||||
// Update slug if name changed
|
||||
if ($validated['name'] !== $event->name) {
|
||||
$validated['slug'] = $this->generateUniqueSlug($validated['name'], $tenantId, $event->id);
|
||||
}
|
||||
|
||||
$event->update($validated);
|
||||
|
||||
$event->load(['eventType', 'tenant']);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Event updated successfully',
|
||||
'data' => new EventResource($event),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified event from storage.
|
||||
*/
|
||||
public function destroy(Request $request, Event $event): JsonResponse
|
||||
{
|
||||
$tenantId = $request->attributes->get('tenant_id');
|
||||
|
||||
// Ensure event belongs to tenant
|
||||
if ($event->tenant_id !== $tenantId) {
|
||||
return response()->json(['error' => 'Event not found'], 404);
|
||||
}
|
||||
|
||||
// Soft delete
|
||||
$event->delete();
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Event deleted successfully',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk update event status (publish/unpublish)
|
||||
*/
|
||||
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,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique slug for event name
|
||||
*/
|
||||
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)
|
||||
->where('id', '!=', $excludeId)
|
||||
->exists()) {
|
||||
$slug = $originalSlug . '-' . $counter;
|
||||
$counter++;
|
||||
}
|
||||
|
||||
return $slug;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search events by name or description
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
135
app/Http/Controllers/Api/Tenant/SettingsController.php
Normal file
135
app/Http/Controllers/Api/Tenant/SettingsController.php
Normal file
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Tenant;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Tenant\SettingsStoreRequest;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class SettingsController extends Controller
|
||||
{
|
||||
/**
|
||||
* Get the tenant's settings.
|
||||
*
|
||||
* @param Request $request
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$tenant = $request->tenant;
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Settings erfolgreich abgerufen.',
|
||||
'data' => [
|
||||
'id' => $tenant->id,
|
||||
'settings' => $tenant->settings ?? [],
|
||||
'updated_at' => $tenant->settings_updated_at?->toISOString(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the tenant's settings.
|
||||
*
|
||||
* @param SettingsStoreRequest $request
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function update(SettingsStoreRequest $request): JsonResponse
|
||||
{
|
||||
$tenant = $request->tenant;
|
||||
|
||||
// Merge new settings with existing ones
|
||||
$currentSettings = $tenant->settings ?? [];
|
||||
$newSettings = array_merge($currentSettings, $request->validated()['settings']);
|
||||
|
||||
$tenant->update([
|
||||
'settings' => $newSettings,
|
||||
'settings_updated_at' => now(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Settings erfolgreich aktualisiert.',
|
||||
'data' => [
|
||||
'id' => $tenant->id,
|
||||
'settings' => $newSettings,
|
||||
'updated_at' => now()->toISOString(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset tenant settings to defaults.
|
||||
*
|
||||
* @param Request $request
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function reset(Request $request): JsonResponse
|
||||
{
|
||||
$tenant = $request->tenant;
|
||||
|
||||
$defaultSettings = [
|
||||
'branding' => [
|
||||
'logo_url' => null,
|
||||
'primary_color' => '#3B82F6',
|
||||
'secondary_color' => '#1F2937',
|
||||
'font_family' => 'Inter, sans-serif',
|
||||
],
|
||||
'features' => [
|
||||
'photo_likes_enabled' => true,
|
||||
'event_checklist' => true,
|
||||
'custom_domain' => false,
|
||||
'advanced_analytics' => false,
|
||||
],
|
||||
'custom_domain' => null,
|
||||
'contact_email' => $tenant->contact_email,
|
||||
'event_default_type' => 'general',
|
||||
];
|
||||
|
||||
$tenant->update([
|
||||
'settings' => $defaultSettings,
|
||||
'settings_updated_at' => now(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Settings auf Standardwerte zurückgesetzt.',
|
||||
'data' => [
|
||||
'id' => $tenant->id,
|
||||
'settings' => $defaultSettings,
|
||||
'updated_at' => now()->toISOString(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate custom domain availability.
|
||||
*
|
||||
* @param Request $request
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function validateDomain(Request $request): JsonResponse
|
||||
{
|
||||
$domain = $request->input('domain');
|
||||
|
||||
if (!$domain) {
|
||||
return response()->json(['error' => 'Domain ist erforderlich.'], 400);
|
||||
}
|
||||
|
||||
// Simple validation - in production, check DNS records or database uniqueness
|
||||
if (!preg_match('/^[a-zA-Z0-9][a-zA-Z0-9-]{1,61}[a-zA-Z0-9]\.[a-zA-Z]{2,}$/', $domain)) {
|
||||
return response()->json([
|
||||
'available' => false,
|
||||
'message' => 'Ungültiges Domain-Format.',
|
||||
]);
|
||||
}
|
||||
|
||||
// Check if domain is already taken by another tenant
|
||||
$taken = Tenant::where('custom_domain', $domain)->where('id', '!=', $request->tenant->id)->exists();
|
||||
|
||||
return response()->json([
|
||||
'available' => !$taken,
|
||||
'message' => $taken ? 'Domain ist bereits vergeben.' : 'Domain ist verfügbar.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
242
app/Http/Controllers/Api/Tenant/TaskController.php
Normal file
242
app/Http/Controllers/Api/Tenant/TaskController.php
Normal file
@@ -0,0 +1,242 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Tenant;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Tenant\TaskStoreRequest;
|
||||
use App\Http\Requests\Tenant\TaskUpdateRequest;
|
||||
use App\Http\Resources\Tenant\TaskResource;
|
||||
use App\Models\Task;
|
||||
use App\Models\TaskCollection;
|
||||
use App\Models\Event;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class TaskController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of the tenant's tasks.
|
||||
*
|
||||
* @param Request $request
|
||||
* @return AnonymousResourceCollection
|
||||
*/
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
$query = Task::where('tenant_id', $request->tenant->id)
|
||||
->with(['taskCollection', 'assignedEvents'])
|
||||
->orderBy('created_at', 'desc');
|
||||
|
||||
// Search and filters
|
||||
if ($search = $request->get('search')) {
|
||||
$query->where('title', 'like', "%{$search}%")
|
||||
->orWhere('description', 'like', "%{$search}%");
|
||||
}
|
||||
|
||||
if ($collectionId = $request->get('collection_id')) {
|
||||
$query->whereHas('taskCollection', fn($q) => $q->where('id', $collectionId));
|
||||
}
|
||||
|
||||
if ($eventId = $request->get('event_id')) {
|
||||
$query->whereHas('assignedEvents', fn($q) => $q->where('id', $eventId));
|
||||
}
|
||||
|
||||
$perPage = $request->get('per_page', 15);
|
||||
$tasks = $query->paginate($perPage);
|
||||
|
||||
return TaskResource::collection($tasks);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created task in storage.
|
||||
*
|
||||
* @param TaskStoreRequest $request
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function store(TaskStoreRequest $request): JsonResponse
|
||||
{
|
||||
$task = Task::create(array_merge($request->validated(), [
|
||||
'tenant_id' => $request->tenant->id,
|
||||
]));
|
||||
|
||||
if ($collectionId = $request->input('collection_id')) {
|
||||
$task->taskCollection()->associate(TaskCollection::findOrFail($collectionId));
|
||||
$task->save();
|
||||
}
|
||||
|
||||
$task->load(['taskCollection', 'assignedEvents']);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Task erfolgreich erstellt.',
|
||||
'data' => new TaskResource($task),
|
||||
], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the specified task.
|
||||
*
|
||||
* @param Request $request
|
||||
* @param Task $task
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function show(Request $request, Task $task): JsonResponse
|
||||
{
|
||||
if ($task->tenant_id !== $request->tenant->id) {
|
||||
abort(404, 'Task nicht gefunden.');
|
||||
}
|
||||
|
||||
$task->load(['taskCollection', 'assignedEvents']);
|
||||
|
||||
return response()->json(new TaskResource($task));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified task in storage.
|
||||
*
|
||||
* @param TaskUpdateRequest $request
|
||||
* @param Task $task
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function update(TaskUpdateRequest $request, Task $task): JsonResponse
|
||||
{
|
||||
if ($task->tenant_id !== $request->tenant->id) {
|
||||
abort(404, 'Task nicht gefunden.');
|
||||
}
|
||||
|
||||
$task->update($request->validated());
|
||||
|
||||
if ($collectionId = $request->input('collection_id')) {
|
||||
$task->taskCollection()->associate(TaskCollection::findOrFail($collectionId));
|
||||
$task->save();
|
||||
}
|
||||
|
||||
$task->load(['taskCollection', 'assignedEvents']);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Task erfolgreich aktualisiert.',
|
||||
'data' => new TaskResource($task),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified task from storage.
|
||||
*
|
||||
* @param Request $request
|
||||
* @param Task $task
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function destroy(Request $request, Task $task): JsonResponse
|
||||
{
|
||||
if ($task->tenant_id !== $request->tenant->id) {
|
||||
abort(404, 'Task nicht gefunden.');
|
||||
}
|
||||
|
||||
$task->delete();
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Task erfolgreich gelöscht.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign task to an event.
|
||||
*
|
||||
* @param Request $request
|
||||
* @param Task $task
|
||||
* @param Event $event
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function assignToEvent(Request $request, Task $task, Event $event): JsonResponse
|
||||
{
|
||||
if ($task->tenant_id !== $request->tenant->id || $event->tenant_id !== $request->tenant->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if ($task->assignedEvents()->where('event_id', $event->id)->exists()) {
|
||||
return response()->json(['message' => 'Task ist bereits diesem Event zugewiesen.'], 409);
|
||||
}
|
||||
|
||||
$task->assignedEvents()->attach($event->id);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Task erfolgreich dem Event zugewiesen.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk assign tasks to an event.
|
||||
*
|
||||
* @param Request $request
|
||||
* @param Event $event
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function bulkAssignToEvent(Request $request, Event $event): JsonResponse
|
||||
{
|
||||
if ($event->tenant_id !== $request->tenant->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$taskIds = $request->input('task_ids', []);
|
||||
if (empty($taskIds)) {
|
||||
return response()->json(['error' => 'Keine Task-IDs angegeben.'], 400);
|
||||
}
|
||||
|
||||
$tasks = Task::whereIn('id', $taskIds)
|
||||
->where('tenant_id', $request->tenant->id)
|
||||
->get();
|
||||
|
||||
$attached = 0;
|
||||
foreach ($tasks as $task) {
|
||||
if (!$task->assignedEvents()->where('event_id', $event->id)->exists()) {
|
||||
$task->assignedEvents()->attach($event->id);
|
||||
$attached++;
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => "{$attached} Tasks dem Event zugewiesen.",
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tasks for a specific event.
|
||||
*
|
||||
* @param Request $request
|
||||
* @param Event $event
|
||||
* @return AnonymousResourceCollection
|
||||
*/
|
||||
public function forEvent(Request $request, Event $event): AnonymousResourceCollection
|
||||
{
|
||||
if ($event->tenant_id !== $request->tenant->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$tasks = Task::whereHas('assignedEvents', fn($q) => $q->where('event_id', $event->id))
|
||||
->with(['taskCollection'])
|
||||
->orderBy('created_at', 'desc')
|
||||
->paginate($request->get('per_page', 15));
|
||||
|
||||
return TaskResource::collection($tasks);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tasks from a specific collection.
|
||||
*
|
||||
* @param Request $request
|
||||
* @param TaskCollection $collection
|
||||
* @return AnonymousResourceCollection
|
||||
*/
|
||||
public function fromCollection(Request $request, TaskCollection $collection): AnonymousResourceCollection
|
||||
{
|
||||
if ($collection->tenant_id !== $request->tenant->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$tasks = $collection->tasks()
|
||||
->with(['assignedEvents'])
|
||||
->orderBy('created_at', 'desc')
|
||||
->paginate($request->get('per_page', 15));
|
||||
|
||||
return TaskResource::collection($tasks);
|
||||
}
|
||||
}
|
||||
376
app/Http/Controllers/OAuthController.php
Normal file
376
app/Http/Controllers/OAuthController.php
Normal file
@@ -0,0 +1,376 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\OAuthClient;
|
||||
use App\Models\OAuthCode;
|
||||
use App\Models\RefreshToken;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantToken;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Firebase\JWT\JWT;
|
||||
use Firebase\JWT\Key;
|
||||
use GuzzleHttp\Client;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class OAuthController extends Controller
|
||||
{
|
||||
/**
|
||||
* Authorize endpoint - PKCE flow
|
||||
*/
|
||||
public function authorize(Request $request)
|
||||
{
|
||||
$validator = Validator::make($request->all(), [
|
||||
'client_id' => 'required|string',
|
||||
'redirect_uri' => 'required|url',
|
||||
'scope' => 'required|string',
|
||||
'state' => 'nullable|string',
|
||||
'code_challenge' => 'required|string',
|
||||
'code_challenge_method' => 'required|in:S256,plain',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return $this->errorResponse('Invalid request parameters', 400, $validator->errors());
|
||||
}
|
||||
|
||||
$client = OAuthClient::where('client_id', $request->client_id)
|
||||
->where('is_active', true)
|
||||
->first();
|
||||
|
||||
if (!$client) {
|
||||
return $this->errorResponse('Invalid client', 401);
|
||||
}
|
||||
|
||||
// Validate redirect URI
|
||||
$redirectUris = is_array($client->redirect_uris) ? $client->redirect_uris : json_decode($client->redirect_uris, true);
|
||||
if (!in_array($request->redirect_uri, $redirectUris)) {
|
||||
return $this->errorResponse('Invalid redirect URI', 400);
|
||||
}
|
||||
|
||||
// Validate scopes
|
||||
$requestedScopes = explode(' ', $request->scope);
|
||||
$availableScopes = is_array($client->scopes) ? array_keys($client->scopes) : [];
|
||||
$validScopes = array_intersect($requestedScopes, $availableScopes);
|
||||
|
||||
if (count($validScopes) !== count($requestedScopes)) {
|
||||
return $this->errorResponse('Invalid scopes requested', 400);
|
||||
}
|
||||
|
||||
// Generate authorization code (PKCE validated later)
|
||||
$code = Str::random(40);
|
||||
$codeId = Str::uuid();
|
||||
|
||||
// Store code in Redis with 5min TTL
|
||||
Cache::put("oauth_code:{$codeId}", [
|
||||
'code' => $code,
|
||||
'client_id' => $request->client_id,
|
||||
'redirect_uri' => $request->redirect_uri,
|
||||
'scopes' => $validScopes,
|
||||
'state' => $request->state ?? null,
|
||||
'code_challenge' => $request->code_challenge,
|
||||
'code_challenge_method' => $request->code_challenge_method,
|
||||
'expires_at' => now()->addMinutes(5),
|
||||
], now()->addMinutes(5));
|
||||
|
||||
// Save to DB for persistence
|
||||
OAuthCode::create([
|
||||
'id' => $codeId,
|
||||
'code' => Hash::make($code),
|
||||
'client_id' => $request->client_id,
|
||||
'scopes' => json_encode($validScopes),
|
||||
'state' => $request->state ?? null,
|
||||
'expires_at' => now()->addMinutes(5),
|
||||
]);
|
||||
|
||||
$redirectUrl = $request->redirect_uri . '?' . http_build_query([
|
||||
'code' => $code,
|
||||
'state' => $request->state ?? '',
|
||||
]);
|
||||
|
||||
return redirect($redirectUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Token endpoint - Code exchange for access/refresh tokens
|
||||
*/
|
||||
public function token(Request $request)
|
||||
{
|
||||
$validator = Validator::make($request->all(), [
|
||||
'grant_type' => 'required|in:authorization_code',
|
||||
'code' => 'required|string',
|
||||
'client_id' => 'required|string',
|
||||
'redirect_uri' => 'required|url',
|
||||
'code_verifier' => 'required|string', // PKCE
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return $this->errorResponse('Invalid request parameters', 400, $validator->errors());
|
||||
}
|
||||
|
||||
// Find the code in cache/DB
|
||||
$cachedCode = Cache::get($request->code);
|
||||
if (!$cachedCode) {
|
||||
return $this->errorResponse('Invalid authorization code', 400);
|
||||
}
|
||||
|
||||
$codeId = array_search($request->code, Cache::get($request->code)['code'] ?? []);
|
||||
if (!$codeId) {
|
||||
$oauthCode = OAuthCode::where('code', 'LIKE', '%' . $request->code . '%')->first();
|
||||
if (!$oauthCode || !Hash::check($request->code, $oauthCode->code)) {
|
||||
return $this->errorResponse('Invalid authorization code', 400);
|
||||
}
|
||||
$cachedCode = $oauthCode->toArray();
|
||||
$codeId = $oauthCode->id;
|
||||
}
|
||||
|
||||
// Validate client
|
||||
$client = OAuthClient::where('client_id', $request->client_id)->first();
|
||||
if (!$client || !$client->is_active) {
|
||||
return $this->errorResponse('Invalid client', 401);
|
||||
}
|
||||
|
||||
// PKCE validation
|
||||
if ($cachedCode['code_challenge_method'] === 'S256') {
|
||||
$expectedChallenge = $this->base64url_encode(hash('sha256', $request->code_verifier, true));
|
||||
if (!hash_equals($expectedChallenge, $cachedCode['code_challenge'])) {
|
||||
return $this->errorResponse('Invalid code verifier', 400);
|
||||
}
|
||||
} else {
|
||||
if (!hash_equals($request->code_verifier, $cachedCode['code'])) {
|
||||
return $this->errorResponse('Invalid code verifier', 400);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate redirect URI
|
||||
if ($request->redirect_uri !== $cachedCode['redirect_uri']) {
|
||||
return $this->errorResponse('Invalid redirect URI', 400);
|
||||
}
|
||||
|
||||
// Code used - delete/invalidate
|
||||
Cache::forget("oauth_code:{$codeId}");
|
||||
OAuthCode::where('id', $codeId)->delete();
|
||||
|
||||
// Create tenant token (assuming client is tied to tenant - adjust as needed)
|
||||
$tenant = Tenant::where('id', $client->tenant_id ?? 1)->first(); // Default tenant or from client
|
||||
if (!$tenant) {
|
||||
return $this->errorResponse('Tenant not found', 404);
|
||||
}
|
||||
|
||||
// Generate JWT Access Token (RS256)
|
||||
$privateKey = file_get_contents(storage_path('app/private.key')); // Ensure keys exist
|
||||
if (!$privateKey) {
|
||||
$this->generateKeyPair();
|
||||
$privateKey = file_get_contents(storage_path('app/private.key'));
|
||||
}
|
||||
|
||||
$accessToken = $this->generateJWT($tenant->id, $cachedCode['scopes'], 'access', 3600); // 1h
|
||||
$refreshTokenId = Str::uuid();
|
||||
$refreshToken = Str::random(60);
|
||||
|
||||
// Store refresh token
|
||||
RefreshToken::create([
|
||||
'id' => $refreshTokenId,
|
||||
'token' => Hash::make($refreshToken),
|
||||
'tenant_id' => $tenant->id,
|
||||
'client_id' => $request->client_id,
|
||||
'scopes' => json_encode($cachedCode['scopes']),
|
||||
'expires_at' => now()->addDays(30),
|
||||
'ip_address' => $request->ip(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'token_type' => 'Bearer',
|
||||
'access_token' => $accessToken,
|
||||
'refresh_token' => $refreshToken,
|
||||
'expires_in' => 3600,
|
||||
'scope' => implode(' ', $cachedCode['scopes']),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tenant info
|
||||
*/
|
||||
public function me(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
if (!$user) {
|
||||
return $this->errorResponse('Unauthenticated', 401);
|
||||
}
|
||||
|
||||
$tenant = $user->tenant;
|
||||
if (!$tenant) {
|
||||
return $this->errorResponse('Tenant not found', 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => $tenant->name,
|
||||
'slug' => $tenant->slug,
|
||||
'event_credits_balance' => $tenant->event_credits_balance,
|
||||
'subscription_tier' => $tenant->subscription_tier,
|
||||
'subscription_expires_at' => $tenant->subscription_expires_at,
|
||||
'features' => $tenant->features,
|
||||
'scopes' => $request->user()->token()->abilities,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate JWT token
|
||||
*/
|
||||
private function generateJWT($tenantId, $scopes, $type, $expiresIn)
|
||||
{
|
||||
$payload = [
|
||||
'iss' => url('/'),
|
||||
'aud' => 'fotospiel-api',
|
||||
'iat' => time(),
|
||||
'nbf' => time(),
|
||||
'exp' => time() + $expiresIn,
|
||||
'sub' => $tenantId,
|
||||
'scopes' => $scopes,
|
||||
'type' => $type,
|
||||
];
|
||||
|
||||
$publicKey = file_get_contents(storage_path('app/public.key'));
|
||||
$privateKey = file_get_contents(storage_path('app/private.key'));
|
||||
|
||||
if (!$publicKey || !$privateKey) {
|
||||
$this->generateKeyPair();
|
||||
$publicKey = file_get_contents(storage_path('app/public.key'));
|
||||
$privateKey = file_get_contents(storage_path('app/private.key'));
|
||||
}
|
||||
|
||||
return JWT::encode($payload, $privateKey, 'RS256', null, null, null, ['kid' => 'fotospiel-jwt']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate RSA key pair for JWT
|
||||
*/
|
||||
private function generateKeyPair()
|
||||
{
|
||||
$config = [
|
||||
"digest_alg" => OPENSSL_ALGO_SHA256,
|
||||
"private_key_bits" => 2048,
|
||||
"private_key_type" => OPENSSL_KEYTYPE_RSA,
|
||||
];
|
||||
|
||||
$res = openssl_pkey_new($config);
|
||||
if (!$res) {
|
||||
throw new \Exception('Failed to generate key pair');
|
||||
}
|
||||
|
||||
// Get private key
|
||||
openssl_pkey_export($res, $privateKey);
|
||||
file_put_contents(storage_path('app/private.key'), $privateKey);
|
||||
|
||||
// Get public key
|
||||
$pubKey = openssl_pkey_get_details($res);
|
||||
file_put_contents(storage_path('app/public.key'), $pubKey["key"]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Error response helper
|
||||
*/
|
||||
private function errorResponse($message, $status = 400, $errors = null)
|
||||
{
|
||||
$response = ['error' => $message];
|
||||
if ($errors) {
|
||||
$response['errors'] = $errors;
|
||||
}
|
||||
|
||||
return response()->json($response, $status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function for base64url encoding
|
||||
*/
|
||||
private function base64url_encode($data)
|
||||
{
|
||||
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
/**
|
||||
* Stripe Connect OAuth - Start connection
|
||||
*/
|
||||
public function stripeConnect(Request $request)
|
||||
{
|
||||
$tenant = $request->user()->tenant;
|
||||
if (!$tenant) {
|
||||
return response()->json(['error' => 'Tenant not found'], 404);
|
||||
}
|
||||
|
||||
$state = Str::random(40);
|
||||
session(['stripe_state' => $state, 'tenant_id' => $tenant->id]);
|
||||
Cache::put("stripe_connect_state:{$tenant->id}", $state, now()->addMinutes(10));
|
||||
|
||||
$clientId = config('services.stripe.connect_client_id');
|
||||
$redirectUri = url('/api/v1/oauth/stripe-callback');
|
||||
$scopes = 'read_write_payments transfers';
|
||||
|
||||
$authUrl = "https://connect.stripe.com/oauth/authorize?response_type=code&client_id={$clientId}&scope={$scopes}&state={$state}&redirect_uri=" . urlencode($redirectUri);
|
||||
|
||||
return redirect($authUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stripe Connect Callback
|
||||
*/
|
||||
public function stripeCallback(Request $request)
|
||||
{
|
||||
$code = $request->get('code');
|
||||
$state = $request->get('state');
|
||||
$error = $request->get('error');
|
||||
|
||||
if ($error) {
|
||||
return redirect('/admin')->with('error', 'Stripe connection failed: ' . $error);
|
||||
}
|
||||
|
||||
if (!$code || !$state) {
|
||||
return redirect('/admin')->with('error', 'Invalid callback parameters');
|
||||
}
|
||||
|
||||
// Validate state
|
||||
$sessionState = session('stripe_state');
|
||||
if (!hash_equals($state, $sessionState)) {
|
||||
return redirect('/admin')->with('error', 'Invalid state parameter');
|
||||
}
|
||||
|
||||
$client = new Client();
|
||||
$clientId = config('services.stripe.connect_client_id');
|
||||
$secret = config('services.stripe.connect_secret');
|
||||
$redirectUri = url('/api/v1/oauth/stripe-callback');
|
||||
|
||||
try {
|
||||
$response = $client->post('https://connect.stripe.com/oauth/token', [
|
||||
'form_params' => [
|
||||
'grant_type' => 'authorization_code',
|
||||
'client_id' => $clientId,
|
||||
'client_secret' => $secret,
|
||||
'code' => $code,
|
||||
'redirect_uri' => $redirectUri,
|
||||
],
|
||||
]);
|
||||
|
||||
$tokenData = json_decode($response->getBody(), true);
|
||||
|
||||
if (!isset($tokenData['stripe_user_id'])) {
|
||||
return redirect('/admin')->with('error', 'Failed to connect Stripe account');
|
||||
}
|
||||
|
||||
$tenant = Tenant::find(session('tenant_id'));
|
||||
if ($tenant) {
|
||||
$tenant->update(['stripe_account_id' => $tokenData['stripe_user_id']]);
|
||||
}
|
||||
|
||||
session()->forget(['stripe_state', 'tenant_id']);
|
||||
return redirect('/admin')->with('success', 'Stripe account connected successfully');
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Stripe OAuth error: ' . $e->getMessage());
|
||||
return redirect('/admin')->with('error', 'Connection error: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
91
app/Http/Controllers/StripeWebhookController.php
Normal file
91
app/Http/Controllers/StripeWebhookController.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\EventPurchase;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class StripeWebhookController extends Controller
|
||||
{
|
||||
public function handle(Request $request)
|
||||
{
|
||||
$payload = $request->getContent();
|
||||
$sig = $request->header('Stripe-Signature');
|
||||
$secret = config('services.stripe.webhook');
|
||||
|
||||
if (!$secret || !$sig) {
|
||||
abort(400, 'Missing signature');
|
||||
}
|
||||
|
||||
$expectedSig = 'v1=' . hash_hmac('sha256', $payload, $secret);
|
||||
|
||||
if (!hash_equals($expectedSig, $sig)) {
|
||||
abort(400, 'Invalid signature');
|
||||
}
|
||||
|
||||
$event = json_decode($payload, true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
Log::error('Invalid JSON in Stripe webhook: ' . json_last_error_msg());
|
||||
return response('', 200);
|
||||
}
|
||||
|
||||
if ($event['type'] === 'checkout.session.completed') {
|
||||
$session = $event['data']['object'];
|
||||
$receiptId = $session['id'];
|
||||
|
||||
// Idempotency check
|
||||
if (EventPurchase::where('external_receipt_id', $receiptId)->exists()) {
|
||||
return response('', 200);
|
||||
}
|
||||
|
||||
$tenantId = $session['metadata']['tenant_id'] ?? null;
|
||||
|
||||
if (!$tenantId) {
|
||||
Log::warning('No tenant_id in Stripe metadata', ['receipt_id' => $receiptId]);
|
||||
// Dispatch job for retry or manual resolution
|
||||
\App\Jobs\ValidateStripeWebhookJob::dispatch($payload, $sig);
|
||||
return response('', 200);
|
||||
}
|
||||
|
||||
$tenant = Tenant::find($tenantId);
|
||||
|
||||
if (!$tenant) {
|
||||
Log::error('Tenant not found for Stripe webhook', ['tenant_id' => $tenantId]);
|
||||
\App\Jobs\ValidateStripeWebhookJob::dispatch($payload, $sig);
|
||||
return response('', 200);
|
||||
}
|
||||
|
||||
$amount = $session['amount_total'] / 100;
|
||||
$currency = $session['currency'];
|
||||
$eventsPurchased = (int) ($session['metadata']['events_purchased'] ?? 1);
|
||||
|
||||
DB::transaction(function () use ($tenant, $amount, $currency, $eventsPurchased, $receiptId) {
|
||||
$purchase = EventPurchase::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'events_purchased' => $eventsPurchased,
|
||||
'amount' => $amount,
|
||||
'currency' => $currency,
|
||||
'provider' => 'stripe',
|
||||
'external_receipt_id' => $receiptId,
|
||||
'status' => 'completed',
|
||||
'purchased_at' => now(),
|
||||
]);
|
||||
|
||||
$tenant->incrementCredits($eventsPurchased, 'purchase', null, $purchase->id);
|
||||
});
|
||||
|
||||
Log::info('Processed Stripe purchase', ['receipt_id' => $receiptId, 'tenant_id' => $tenantId]);
|
||||
} else {
|
||||
// For other event types, log or dispatch job if needed
|
||||
Log::info('Unhandled Stripe event', ['type' => $event['type']]);
|
||||
// Optionally dispatch job for processing other events
|
||||
\App\Jobs\ValidateStripeWebhookJob::dispatch($payload, $sig);
|
||||
}
|
||||
|
||||
return response('', 200);
|
||||
}
|
||||
}
|
||||
59
app/Http/Controllers/Tenant/CreditController.php
Normal file
59
app/Http/Controllers/Tenant/CreditController.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Tenant;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\Tenant\CreditLedgerResource;
|
||||
use App\Http\Resources\Tenant\EventPurchaseResource;
|
||||
use App\Models\EventCreditsLedger;
|
||||
use App\Models\EventPurchase;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CreditController extends Controller
|
||||
{
|
||||
public function balance(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
if (!$user) {
|
||||
return response()->json(['message' => 'Unauthenticated'], 401);
|
||||
}
|
||||
|
||||
$tenant = Tenant::findOrFail($user->tenant_id);
|
||||
|
||||
return response()->json([
|
||||
'balance' => $tenant->event_credits_balance,
|
||||
'free_event_granted_at' => $tenant->free_event_granted_at,
|
||||
]);
|
||||
}
|
||||
|
||||
public function ledger(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
if (!$user) {
|
||||
return response()->json(['message' => 'Unauthenticated'], 401);
|
||||
}
|
||||
|
||||
$tenant = Tenant::findOrFail($user->tenant_id);
|
||||
$ledgers = EventCreditsLedger::where('tenant_id', $tenant->id)
|
||||
->orderBy('created_at', 'desc')
|
||||
->paginate(20);
|
||||
|
||||
return CreditLedgerResource::collection($ledgers);
|
||||
}
|
||||
|
||||
public function history(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
if (!$user) {
|
||||
return response()->json(['message' => 'Unauthenticated'], 401);
|
||||
}
|
||||
|
||||
$tenant = Tenant::findOrFail($user->tenant_id);
|
||||
$purchases = EventPurchase::where('tenant_id', $tenant->id)
|
||||
->orderBy('purchased_at', 'desc')
|
||||
->paginate(20);
|
||||
|
||||
return EventPurchaseResource::collection($purchases);
|
||||
}
|
||||
}
|
||||
39
app/Http/Middleware/CreditCheckMiddleware.php
Normal file
39
app/Http/Middleware/CreditCheckMiddleware.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\Event;
|
||||
|
||||
class CreditCheckMiddleware
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if ($request->isMethod('post') && $request->routeIs('api.v1.tenant.events.store')) {
|
||||
$user = $request->user();
|
||||
if (!$user) {
|
||||
return response()->json(['message' => 'Unauthenticated'], 401);
|
||||
}
|
||||
|
||||
$tenant = Tenant::findOrFail($user->tenant_id);
|
||||
|
||||
if ($tenant->event_credits_balance < 1) {
|
||||
return response()->json(['message' => 'Insufficient event credits'], 422);
|
||||
}
|
||||
|
||||
$request->merge(['tenant' => $tenant]);
|
||||
} elseif (($request->isMethod('put') || $request->isMethod('patch')) && $request->routeIs('api.v1.tenant.events.update')) {
|
||||
$eventSlug = $request->route('event');
|
||||
$event = Event::where('slug', $eventSlug)->firstOrFail();
|
||||
$tenant = $event->tenant;
|
||||
|
||||
// For update, no credit check needed (already consumed on create)
|
||||
$request->merge(['tenant' => $tenant]);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
61
app/Http/Middleware/TenantIsolation.php
Normal file
61
app/Http/Middleware/TenantIsolation.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class TenantIsolation
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*/
|
||||
public function handle(Request $request, Closure $next)
|
||||
{
|
||||
$tenantId = $request->attributes->get('tenant_id');
|
||||
|
||||
if (!$tenantId) {
|
||||
return response()->json(['error' => 'Tenant ID not found in token'], 401);
|
||||
}
|
||||
|
||||
// Get the tenant from request (query param, route param, or header)
|
||||
$requestTenantId = $this->getTenantIdFromRequest($request);
|
||||
|
||||
if ($requestTenantId && $requestTenantId != $tenantId) {
|
||||
return response()->json(['error' => 'Tenant isolation violation'], 403);
|
||||
}
|
||||
|
||||
// Set tenant context for query scoping
|
||||
DB::statement("SET @tenant_id = ?", [$tenantId]);
|
||||
|
||||
// Add tenant context to request for easy access in controllers
|
||||
$request->attributes->set('current_tenant_id', $tenantId);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract tenant ID from request
|
||||
*/
|
||||
private function getTenantIdFromRequest(Request $request): ?int
|
||||
{
|
||||
// 1. Route parameter (e.g., /api/v1/tenant/123/events)
|
||||
if ($request->route('tenant')) {
|
||||
return (int) $request->route('tenant');
|
||||
}
|
||||
|
||||
// 2. Query parameter (e.g., ?tenant_id=123)
|
||||
if ($request->query('tenant_id')) {
|
||||
return (int) $request->query('tenant_id');
|
||||
}
|
||||
|
||||
// 3. Header (X-Tenant-ID)
|
||||
if ($request->header('X-Tenant-ID')) {
|
||||
return (int) $request->header('X-Tenant-ID');
|
||||
}
|
||||
|
||||
// 4. For tenant-specific resources, use token tenant_id
|
||||
return null;
|
||||
}
|
||||
}
|
||||
162
app/Http/Middleware/TenantTokenGuard.php
Normal file
162
app/Http/Middleware/TenantTokenGuard.php
Normal file
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\TenantToken;
|
||||
use Closure;
|
||||
use Firebase\JWT\Exceptions\TokenExpiredException;
|
||||
use Firebase\JWT\JWT;
|
||||
use Firebase\JWT\Key;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class TenantTokenGuard
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*/
|
||||
public function handle(Request $request, Closure $next, ...$scopes)
|
||||
{
|
||||
$token = $this->getTokenFromRequest($request);
|
||||
|
||||
if (!$token) {
|
||||
return response()->json(['error' => 'Token not provided'], 401);
|
||||
}
|
||||
|
||||
try {
|
||||
$decoded = $this->decodeToken($token);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json(['error' => 'Invalid token'], 401);
|
||||
}
|
||||
|
||||
// Check token blacklist
|
||||
if ($this->isTokenBlacklisted($decoded)) {
|
||||
return response()->json(['error' => 'Token has been revoked'], 401);
|
||||
}
|
||||
|
||||
// Validate scopes if specified
|
||||
if (!empty($scopes) && !$this->hasScopes($decoded, $scopes)) {
|
||||
return response()->json(['error' => 'Insufficient scopes'], 403);
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
if ($decoded['exp'] < time()) {
|
||||
// Add to blacklist on expiry
|
||||
$this->blacklistToken($decoded);
|
||||
return response()->json(['error' => 'Token expired'], 401);
|
||||
}
|
||||
|
||||
// Set tenant ID on request
|
||||
$request->merge(['tenant_id' => $decoded['sub']]);
|
||||
$request->attributes->set('decoded_token', $decoded);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get token from request (Bearer or header)
|
||||
*/
|
||||
private function getTokenFromRequest(Request $request): ?string
|
||||
{
|
||||
$header = $request->header('Authorization');
|
||||
|
||||
if (str_starts_with($header, 'Bearer ')) {
|
||||
return substr($header, 7);
|
||||
}
|
||||
|
||||
if ($request->header('X-API-Token')) {
|
||||
return $request->header('X-API-Token');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode JWT token
|
||||
*/
|
||||
private function decodeToken(string $token): array
|
||||
{
|
||||
$publicKey = file_get_contents(storage_path('app/public.key'));
|
||||
if (!$publicKey) {
|
||||
throw new \Exception('JWT public key not found');
|
||||
}
|
||||
|
||||
$decoded = JWT::decode($token, new Key($publicKey, 'RS256'));
|
||||
return (array) $decoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if token is blacklisted
|
||||
*/
|
||||
private function isTokenBlacklisted(array $decoded): bool
|
||||
{
|
||||
$jti = isset($decoded['jti']) ? $decoded['jti'] : md5($decoded['sub'] . $decoded['iat']);
|
||||
$cacheKey = "blacklisted_token:{$jti}";
|
||||
|
||||
// Check cache first (faster)
|
||||
if (Cache::has($cacheKey)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check DB blacklist
|
||||
$dbJti = $decoded['jti'] ?? null;
|
||||
$blacklisted = TenantToken::where('jti', $dbJti)
|
||||
->orWhere('token_hash', md5($decoded['sub'] . $decoded['iat']))
|
||||
->where('expires_at', '>', now())
|
||||
->exists();
|
||||
|
||||
if ($blacklisted) {
|
||||
Cache::put($cacheKey, true, now()->addMinutes(5));
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add token to blacklist
|
||||
*/
|
||||
private function blacklistToken(array $decoded): void
|
||||
{
|
||||
$jti = $decoded['jti'] ?? md5($decoded['sub'] . $decoded['iat']);
|
||||
$cacheKey = "blacklisted_token:{$jti}";
|
||||
|
||||
// Cache for immediate effect
|
||||
Cache::put($cacheKey, true, $decoded['exp'] - time());
|
||||
|
||||
// Store in DB for persistence
|
||||
TenantToken::updateOrCreate(
|
||||
[
|
||||
'jti' => $jti,
|
||||
'tenant_id' => $decoded['sub'],
|
||||
],
|
||||
[
|
||||
'token_hash' => md5(json_encode($decoded)),
|
||||
'ip_address' => request()->ip(),
|
||||
'user_agent' => request()->userAgent(),
|
||||
'expires_at' => now()->addHours(24), // Keep for 24h after expiry
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if token has required scopes
|
||||
*/
|
||||
private function hasScopes(array $decoded, array $requiredScopes): bool
|
||||
{
|
||||
$tokenScopes = $decoded['scopes'] ?? [];
|
||||
|
||||
if (!is_array($tokenScopes)) {
|
||||
$tokenScopes = explode(' ', $tokenScopes);
|
||||
}
|
||||
|
||||
foreach ($requiredScopes as $scope) {
|
||||
if (!in_array($scope, $tokenScopes)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
70
app/Http/Requests/Tenant/EventStoreRequest.php
Normal file
70
app/Http/Requests/Tenant/EventStoreRequest.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Tenant;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class EventStoreRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true; // Authorization handled by middleware
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
$tenantId = request()->attributes->get('tenant_id');
|
||||
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'description' => ['nullable', 'string'],
|
||||
'event_date' => ['required', 'date', 'after_or_equal:today'],
|
||||
'location' => ['nullable', 'string', 'max:255'],
|
||||
'event_type_id' => ['required', 'exists:event_types,id'],
|
||||
'max_participants' => ['nullable', 'integer', 'min:1', 'max:10000'],
|
||||
'public_url' => ['nullable', 'url', 'max:500'],
|
||||
'custom_domain' => ['nullable', 'string', 'max:255'],
|
||||
'theme_color' => ['nullable', 'string', 'regex:/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/'],
|
||||
'logo_image' => ['nullable', 'image', 'max:2048'], // 2MB
|
||||
'cover_image' => ['nullable', 'image', 'max:5120'], // 5MB
|
||||
'password_protected' => ['nullable', 'boolean'],
|
||||
'password' => ['required_if:password_protected,true', 'string', 'min:6', 'confirmed'],
|
||||
'status' => ['nullable', Rule::in(['draft', 'published', 'archived'])],
|
||||
'features' => ['nullable', 'array'],
|
||||
'features.*' => ['string'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get custom validation messages.
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'event_date.after_or_equal' => 'Das Event-Datum darf nicht in der Vergangenheit liegen.',
|
||||
'password.confirmed' => 'Die Passwortbestätigung stimmt nicht überein.',
|
||||
'logo_image.image' => 'Das Logo muss ein Bild sein.',
|
||||
'cover_image.image' => 'Das Cover-Bild muss ein Bild sein.',
|
||||
'theme_color.regex' => 'Die Farbe muss im Hex-Format angegeben werden (z.B. #FF0000).',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare the data for validation.
|
||||
*/
|
||||
protected function prepareForValidation()
|
||||
{
|
||||
$this->merge([
|
||||
'password_protected' => $this->boolean('password_protected'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
62
app/Http/Requests/Tenant/PhotoStoreRequest.php
Normal file
62
app/Http/Requests/Tenant/PhotoStoreRequest.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Tenant;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class PhotoStoreRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true; // Authorization handled by middleware
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'photo' => [
|
||||
'required',
|
||||
'image',
|
||||
'mimes:jpeg,png,webp',
|
||||
'max:10240', // 10MB
|
||||
],
|
||||
'caption' => ['nullable', 'string', 'max:500'],
|
||||
'alt_text' => ['nullable', 'string', 'max:255'],
|
||||
'tags' => ['nullable', 'array', 'max:10'],
|
||||
'tags.*' => ['string', 'max:50'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get custom validation messages.
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'photo.required' => 'Ein Foto muss hochgeladen werden.',
|
||||
'photo.image' => 'Die Datei muss ein Bild sein.',
|
||||
'photo.mimes' => 'Nur JPEG, PNG und WebP Formate sind erlaubt.',
|
||||
'photo.max' => 'Das Foto darf maximal 10MB groß sein.',
|
||||
'caption.max' => 'Die Bildunterschrift darf maximal 500 Zeichen haben.',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare the data for validation.
|
||||
*/
|
||||
protected function prepareForValidation()
|
||||
{
|
||||
$this->merge([
|
||||
'tags' => $this->tags ? explode(',', $this->tags) : [],
|
||||
]);
|
||||
}
|
||||
}
|
||||
66
app/Http/Requests/Tenant/SettingsStoreRequest.php
Normal file
66
app/Http/Requests/Tenant/SettingsStoreRequest.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Tenant;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class SettingsStoreRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'settings' => ['required', 'array'],
|
||||
'settings.branding' => ['sometimes', 'array'],
|
||||
'settings.branding.logo_url' => ['nullable', 'url', 'max:500'],
|
||||
'settings.branding.primary_color' => ['nullable', 'regex:/^#[0-9A-Fa-f]{6}$/'],
|
||||
'settings.branding.secondary_color' => ['nullable', 'regex:/^#[0-9A-Fa-f]{6}$/'],
|
||||
'settings.branding.font_family' => ['nullable', 'string', 'max:100'],
|
||||
'settings.features' => ['sometimes', 'array'],
|
||||
'settings.features.photo_likes_enabled' => ['nullable', 'boolean'],
|
||||
'settings.features.event_checklist' => ['nullable', 'boolean'],
|
||||
'settings.features.custom_domain' => ['nullable', 'boolean'],
|
||||
'settings.features.advanced_analytics' => ['nullable', 'boolean'],
|
||||
'settings.custom_domain' => ['nullable', 'string', 'max:255', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9-]{1,61}[a-zA-Z0-9]\.[a-zA-Z]{2,}$/'],
|
||||
'settings.contact_email' => ['nullable', 'email', 'max:255'],
|
||||
'settings.event_default_type' => ['nullable', 'string', 'max:50'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get custom messages for validator errors.
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'settings.required' => 'Settings-Daten sind erforderlich.',
|
||||
'settings.branding.logo_url.url' => 'Die Logo-URL muss eine gültige URL sein.',
|
||||
'settings.branding.primary_color.regex' => 'Die Primärfarbe muss ein gültiges Hex-Format (#RRGGBB) haben.',
|
||||
'settings.branding.secondary_color.regex' => 'Die Sekundärfarbe muss ein gültiges Hex-Format (#RRGGBB) haben.',
|
||||
'settings.custom_domain.regex' => 'Das Custom Domain muss ein gültiges Domain-Format haben.',
|
||||
'settings.contact_email.email' => 'Die Kontakt-E-Mail muss eine gültige E-Mail-Adresse sein.',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare the data for validation.
|
||||
*/
|
||||
protected function prepareForValidation()
|
||||
{
|
||||
$this->merge([
|
||||
'settings' => $this->input('settings', []),
|
||||
]);
|
||||
}
|
||||
}
|
||||
60
app/Http/Requests/Tenant/TaskStoreRequest.php
Normal file
60
app/Http/Requests/Tenant/TaskStoreRequest.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Tenant;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class TaskStoreRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'title' => ['required', 'string', 'max:255'],
|
||||
'description' => ['nullable', 'string'],
|
||||
'collection_id' => ['nullable', 'exists:task_collections,id', function ($attribute, $value, $fail) {
|
||||
$tenantId = request()->tenant?->id;
|
||||
if ($tenantId && !\App\Models\TaskCollection::where('id', $value)->where('tenant_id', $tenantId)->exists()) {
|
||||
$fail('Die TaskCollection gehört nicht zu diesem Tenant.');
|
||||
}
|
||||
}],
|
||||
'priority' => ['nullable', Rule::in(['low', 'medium', 'high', 'urgent'])],
|
||||
'due_date' => ['nullable', 'date', 'after:now'],
|
||||
'is_completed' => ['nullable', 'boolean'],
|
||||
'assigned_to' => ['nullable', 'exists:users,id', function ($attribute, $value, $fail) {
|
||||
$tenantId = request()->tenant?->id;
|
||||
if ($tenantId && !\App\Models\User::where('id', $value)->where('tenant_id', $tenantId)->exists()) {
|
||||
$fail('Der Benutzer gehört nicht zu diesem Tenant.');
|
||||
}
|
||||
}],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get custom messages for validator errors.
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'title.required' => 'Der Task-Titel ist erforderlich.',
|
||||
'title.max' => 'Der Task-Titel darf maximal 255 Zeichen haben.',
|
||||
'collection_id.exists' => 'Die ausgewählte TaskCollection existiert nicht.',
|
||||
'priority.in' => 'Die Priorität muss low, medium, high oder urgent sein.',
|
||||
'due_date.after' => 'Das Fälligkeitsdatum muss in der Zukunft liegen.',
|
||||
'assigned_to.exists' => 'Der zugewiesene Benutzer existiert nicht.',
|
||||
];
|
||||
}
|
||||
}
|
||||
59
app/Http/Requests/Tenant/TaskUpdateRequest.php
Normal file
59
app/Http/Requests/Tenant/TaskUpdateRequest.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Tenant;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class TaskUpdateRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'title' => ['sometimes', 'required', 'string', 'max:255'],
|
||||
'description' => ['sometimes', 'nullable', 'string'],
|
||||
'collection_id' => ['sometimes', 'nullable', 'exists:task_collections,id', function ($attribute, $value, $fail) {
|
||||
$tenantId = request()->tenant?->id;
|
||||
if ($tenantId && !\App\Models\TaskCollection::where('id', $value)->where('tenant_id', $tenantId)->exists()) {
|
||||
$fail('Die TaskCollection gehört nicht zu diesem Tenant.');
|
||||
}
|
||||
}],
|
||||
'priority' => ['sometimes', 'nullable', Rule::in(['low', 'medium', 'high', 'urgent'])],
|
||||
'due_date' => ['sometimes', 'nullable', 'date'],
|
||||
'is_completed' => ['sometimes', 'boolean'],
|
||||
'assigned_to' => ['sometimes', 'nullable', 'exists:users,id', function ($attribute, $value, $fail) {
|
||||
$tenantId = request()->tenant?->id;
|
||||
if ($tenantId && !\App\Models\User::where('id', $value)->where('tenant_id', $tenantId)->exists()) {
|
||||
$fail('Der Benutzer gehört nicht zu diesem Tenant.');
|
||||
}
|
||||
}],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get custom messages for validator errors.
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'title.required' => 'Der Task-Titel ist erforderlich.',
|
||||
'title.max' => 'Der Task-Titel darf maximal 255 Zeichen haben.',
|
||||
'collection_id.exists' => 'Die ausgewählte TaskCollection existiert nicht.',
|
||||
'priority.in' => 'Die Priorität muss low, medium, high oder urgent sein.',
|
||||
'assigned_to.exists' => 'Der zugewiesene Benutzer existiert nicht.',
|
||||
];
|
||||
}
|
||||
}
|
||||
21
app/Http/Resources/Tenant/CreditLedgerResource.php
Normal file
21
app/Http/Resources/Tenant/CreditLedgerResource.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources\Tenant;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class CreditLedgerResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'delta' => $this->delta,
|
||||
'reason' => $this->reason,
|
||||
'note' => $this->note,
|
||||
'related_purchase_id' => $this->related_purchase_id,
|
||||
'created_at' => $this->created_at->toISOString(),
|
||||
];
|
||||
}
|
||||
}
|
||||
24
app/Http/Resources/Tenant/EventPurchaseResource.php
Normal file
24
app/Http/Resources/Tenant/EventPurchaseResource.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources\Tenant;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class EventPurchaseResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'events_purchased' => $this->events_purchased,
|
||||
'amount' => $this->amount,
|
||||
'currency' => $this->currency,
|
||||
'provider' => $this->provider,
|
||||
'status' => $this->status,
|
||||
'external_receipt_id' => $this->external_receipt_id,
|
||||
'purchased_at' => $this->purchased_at ? $this->purchased_at->toISOString() : null,
|
||||
'created_at' => $this->created_at->toISOString(),
|
||||
];
|
||||
}
|
||||
}
|
||||
56
app/Http/Resources/Tenant/EventResource.php
Normal file
56
app/Http/Resources/Tenant/EventResource.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources\Tenant;
|
||||
|
||||
use App\Http\Resources\Tenant\EventTypeResource;
|
||||
use App\Http\Resources\Tenant\PhotoResource;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class EventResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
$tenantId = $request->attributes->get('tenant_id');
|
||||
|
||||
// Hide sensitive data for other tenants
|
||||
$showSensitive = $this->tenant_id === $tenantId;
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'slug' => $this->slug,
|
||||
'description' => $this->description,
|
||||
'event_date' => $this->event_date ? $this->event_date->toISOString() : null,
|
||||
'location' => $this->location,
|
||||
'max_participants' => $this->max_participants,
|
||||
'current_participants' => $showSensitive ? $this->photos_count : null,
|
||||
'public_url' => $this->public_url,
|
||||
'custom_domain' => $showSensitive ? $this->custom_domain : null,
|
||||
'theme_color' => $this->theme_color,
|
||||
'status' => $showSensitive ? $this->status : 'published',
|
||||
'password_protected' => $this->password_protected,
|
||||
'features' => $this->features,
|
||||
'event_type' => new EventTypeResource($this->whenLoaded('eventType')),
|
||||
'photos' => PhotoResource::collection($this->whenLoaded('photos')),
|
||||
'tasks' => $showSensitive ? $this->whenLoaded('tasks') : [],
|
||||
'tenant' => $showSensitive ? [
|
||||
'id' => $this->tenant->id,
|
||||
'name' => $this->tenant->name,
|
||||
'event_credits_balance' => $this->tenant->event_credits_balance,
|
||||
] : null,
|
||||
'created_at' => $this->created_at->toISOString(),
|
||||
'updated_at' => $this->updated_at->toISOString(),
|
||||
'photo_count' => $this->photos_count,
|
||||
'like_count' => $this->photos->sum('likes_count'),
|
||||
'is_public' => $this->status === 'published' && !$this->password_protected,
|
||||
'public_share_url' => $showSensitive ? route('api.v1.events.show', ['slug' => $this->slug]) : null,
|
||||
'qr_code_url' => $showSensitive ? route('api.v1.events.qr', ['event' => $this->id]) : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
28
app/Http/Resources/Tenant/EventTypeResource.php
Normal file
28
app/Http/Resources/Tenant/EventTypeResource.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources\Tenant;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class EventTypeResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'description' => $this->description,
|
||||
'icon' => $this->icon,
|
||||
'color' => $this->color,
|
||||
'is_active' => $this->is_active,
|
||||
'created_at' => $this->created_at->toISOString(),
|
||||
'updated_at' => $this->updated_at->toISOString(),
|
||||
];
|
||||
}
|
||||
}
|
||||
58
app/Http/Resources/Tenant/PhotoResource.php
Normal file
58
app/Http/Resources/Tenant/PhotoResource.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources\Tenant;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class PhotoResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
$tenantId = $request->attributes->get('tenant_id');
|
||||
$showSensitive = $this->event->tenant_id === $tenantId;
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'filename' => $this->filename,
|
||||
'original_name' => $this->original_name,
|
||||
'mime_type' => $this->mime_type,
|
||||
'size' => $this->size,
|
||||
'url' => $showSensitive ? $this->getFullUrl() : $this->getThumbnailUrl(),
|
||||
'thumbnail_url' => $this->getThumbnailUrl(),
|
||||
'width' => $this->width,
|
||||
'height' => $this->height,
|
||||
'status' => $showSensitive ? $this->status : 'approved',
|
||||
'moderation_notes' => $showSensitive ? $this->moderation_notes : null,
|
||||
'likes_count' => $this->likes_count,
|
||||
'is_liked' => $showSensitive ? $this->isLikedByTenant($tenantId) : false,
|
||||
'uploaded_at' => $this->created_at->toISOString(),
|
||||
'event' => [
|
||||
'id' => $this->event->id,
|
||||
'name' => $this->event->name,
|
||||
'slug' => $this->event->slug,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full image URL
|
||||
*/
|
||||
private function getFullUrl(): string
|
||||
{
|
||||
return url("storage/events/{$this->event->slug}/photos/{$this->filename}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get thumbnail URL
|
||||
*/
|
||||
private function getThumbnailUrl(): string
|
||||
{
|
||||
return url("storage/events/{$this->event->slug}/thumbnails/{$this->filename}");
|
||||
}
|
||||
}
|
||||
42
app/Http/Resources/Tenant/TaskResource.php
Normal file
42
app/Http/Resources/Tenant/TaskResource.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources\Tenant;
|
||||
|
||||
use App\Http\Resources\Tenant\EventResource;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class TaskResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'title' => $this->title,
|
||||
'description' => $this->description,
|
||||
'priority' => $this->priority,
|
||||
'due_date' => $this->due_date?->toISOString(),
|
||||
'is_completed' => $this->is_completed,
|
||||
'collection_id' => $this->collection_id,
|
||||
'assigned_events_count' => $this->assignedEvents()->count(),
|
||||
// TaskCollectionResource wird später implementiert
|
||||
// 'collection' => $this->whenLoaded('taskCollection', function () {
|
||||
// return new TaskCollectionResource($this->taskCollection);
|
||||
// }),
|
||||
'assigned_events' => $this->whenLoaded('assignedEvents', function () {
|
||||
return EventResource::collection($this->assignedEvents);
|
||||
}),
|
||||
// UserResource wird später implementiert
|
||||
// 'assigned_to' => $this->whenLoaded('assignedTo', function () {
|
||||
// return new UserResource($this->assignedTo);
|
||||
// }),
|
||||
'created_at' => $this->created_at->toISOString(),
|
||||
'updated_at' => $this->updated_at->toISOString(),
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user