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:
2025-09-17 19:56:54 +02:00
parent 5fbb9cb240
commit 42d6e98dff
84 changed files with 6125 additions and 155 deletions

View 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);
}
}

View 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);
}
}

View 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.',
]);
}
}

View 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);
}
}

View 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());
}
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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;
}
}

View 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;
}
}

View 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'),
]);
}
}

View 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) : [],
]);
}
}

View 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', []),
]);
}
}

View 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.',
];
}
}

View 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.',
];
}
}

View 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(),
];
}
}

View 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(),
];
}
}

View 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,
];
}
}

View 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(),
];
}
}

View 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}");
}
}

View 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(),
];
}
}