diff --git a/.env.example b/.env.example index 35db1dd..4514146 100644 --- a/.env.example +++ b/.env.example @@ -62,4 +62,11 @@ AWS_DEFAULT_REGION=us-east-1 AWS_BUCKET= AWS_USE_PATH_STYLE_ENDPOINT=false +# Stripe +STRIPE_KEY= +STRIPE_SECRET= +STRIPE_WEBHOOK_SECRET= +STRIPE_CONNECT_CLIENT_ID= +STRIPE_CONNECT_SECRET= + VITE_APP_NAME="${APP_NAME}" diff --git a/app/Exports/EventPurchaseExporter.php b/app/Exports/EventPurchaseExporter.php new file mode 100644 index 0000000..822a2da --- /dev/null +++ b/app/Exports/EventPurchaseExporter.php @@ -0,0 +1,38 @@ +successful_rows} purchases were exported."; + + return $body; + } +} \ No newline at end of file diff --git a/app/Filament/Resources/EventPurchaseResource.php b/app/Filament/Resources/EventPurchaseResource.php new file mode 100644 index 0000000..0591bd2 --- /dev/null +++ b/app/Filament/Resources/EventPurchaseResource.php @@ -0,0 +1,211 @@ +components([ + Select::make('tenant_id') + ->label('Tenant') + ->relationship('tenant', 'name') + ->searchable() + ->preload() + ->required(), + Select::make('package_id') + ->label('Paket') + ->options([ + 'starter_pack' => 'Starter Pack (5 Events)', + 'pro_pack' => 'Pro Pack (20 Events)', + 'lifetime_unlimited' => 'Lifetime Unlimited', + 'monthly_pro' => 'Pro Subscription', + 'monthly_agency' => 'Agency Subscription', + ]) + ->required(), + TextInput::make('credits_added') + ->label('Credits hinzugefügt') + ->numeric() + ->required() + ->minValue(0), + TextInput::make('price') + ->label('Preis') + ->money('EUR') + ->required(), + Select::make('platform') + ->label('Plattform') + ->options([ + 'ios' => 'iOS', + 'android' => 'Android', + 'web' => 'Web', + 'manual' => 'Manuell', + ]) + ->required(), + TextInput::make('transaction_id') + ->label('Transaktions-ID') + ->maxLength(255), + Textarea::make('reason') + ->label('Beschreibung') + ->maxLength(65535) + ->columnSpanFull(), + ]) + ->columns(2); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + TextColumn::make('tenant.name') + ->label('Tenant') + ->searchable() + ->sortable(), + TextColumn::make('package_id') + ->label('Paket') + ->badge() + ->color(fn (string $state): string => match($state) { + 'starter_pack' => 'info', + 'pro_pack' => 'success', + 'lifetime_unlimited' => 'danger', + 'monthly_pro' => 'warning', + default => 'gray', + }), + TextColumn::make('credits_added') + ->label('Credits') + ->badge() + ->color('success'), + TextColumn::make('price') + ->label('Preis') + ->money('EUR') + ->sortable(), + TextColumn::make('platform') + ->badge() + ->color(fn (string $state): string => match($state) { + 'ios' => 'info', + 'android' => 'success', + 'web' => 'warning', + 'manual' => 'gray', + }), + TextColumn::make('purchased_at') + ->dateTime() + ->sortable(), + TextColumn::make('transaction_id') + ->copyable() + ->toggleable(), + ]) + ->filters([ + Filter::make('created_at') + ->form([ + DatePicker::make('started_from'), + DatePicker::make('ended_before'), + ]) + ->query(function (Builder $query, array $data): Builder { + return $query + ->when( + $data['started_from'], + fn (Builder $query, $date): Builder => $query->whereDate('created_at', '>=', $date), + ) + ->when( + $data['ended_before'], + fn (Builder $query, $date): Builder => $query->whereDate('created_at', '<=', $date), + ); + }), + SelectFilter::make('platform') + ->options([ + 'ios' => 'iOS', + 'android' => 'Android', + 'web' => 'Web', + 'manual' => 'Manuell', + ]), + SelectFilter::make('package_id') + ->options([ + 'starter_pack' => 'Starter Pack', + 'pro_pack' => 'Pro Pack', + 'lifetime_unlimited' => 'Lifetime', + 'monthly_pro' => 'Pro Subscription', + 'monthly_agency' => 'Agency Subscription', + ]), + TernaryFilter::make('successful') + ->label('Erfolgreich') + ->trueLabel('Ja') + ->falseLabel('Nein') + ->placeholder('Alle') + ->query(fn (Builder $query): Builder => $query->whereNotNull('transaction_id')), + ]) + ->actions([ + ViewAction::make(), + Action::make('refund') + ->label('Rückerstattung') + ->color('danger') + ->icon('heroicon-o-arrow-uturn-left') + ->requiresConfirmation() + ->visible(fn (EventPurchase $record): bool => $record->transaction_id && is_null($record->refunded_at)) + ->action(function (EventPurchase $record) { + $record->update(['refunded_at' => now()]); + $record->tenant->decrement('event_credits_balance', $record->credits_added); + Log::info('Refund processed for purchase ID: ' . $record->id); + }), + ]) + ->bulkActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ExportBulkAction::make() + ->label('Export CSV') + ->exporter(EventPurchaseExporter::class), + ]), + ]) + ->emptyStateHeading('Keine Käufe gefunden') + ->emptyStateDescription('Erstelle den ersten Kauf oder überprüfe die Filter.'); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListEventPurchases::route('/'), + 'create' => Pages\CreateEventPurchase::route('/create'), + 'view' => Pages\ViewEventPurchase::route('/{record}'), + 'edit' => Pages\EditEventPurchase::route('/{record}/edit'), + ]; + } +} \ No newline at end of file diff --git a/app/Filament/Resources/EventPurchaseResource/Pages/CreateEventPurchase.php b/app/Filament/Resources/EventPurchaseResource/Pages/CreateEventPurchase.php new file mode 100644 index 0000000..69dd6a5 --- /dev/null +++ b/app/Filament/Resources/EventPurchaseResource/Pages/CreateEventPurchase.php @@ -0,0 +1,11 @@ +label(__('admin.tenants.fields.event_credits_balance')) ->numeric() ->default(0), + Select::make('subscription_tier') + ->label(__('admin.tenants.fields.subscription_tier')) + ->options([ + 'free' => 'Free', + 'starter' => 'Starter (€4.99/mo)', + 'pro' => 'Pro (€14.99/mo)', + 'agency' => 'Agency (€19.99/mo)', + 'lifetime' => 'Lifetime (€49.99)' + ]) + ->default('free'), + DateTimePicker::make('subscription_expires_at') + ->label(__('admin.tenants.fields.subscription_expires_at')), + TextInput::make('total_revenue') + ->label(__('admin.tenants.fields.total_revenue')) + ->money('EUR') + ->readOnly(), + Toggle::make('is_active') + ->label(__('admin.tenants.fields.is_active')) + ->default(true), + Toggle::make('is_suspended') + ->label(__('admin.tenants.fields.is_suspended')) + ->default(false), KeyValue::make('features') ->label(__('admin.tenants.fields.features')) ->keyLabel(__('admin.common.key')) @@ -65,13 +92,60 @@ class TenantResource extends Resource Tables\Columns\TextColumn::make('name')->searchable()->sortable(), Tables\Columns\TextColumn::make('slug')->searchable(), Tables\Columns\TextColumn::make('contact_email'), - Tables\Columns\TextColumn::make('event_credits_balance')->label(__('admin.common.credits')), + Tables\Columns\TextColumn::make('event_credits_balance') + ->label(__('admin.common.credits')) + ->badge() + ->color(fn ($state) => $state < 5 ? 'warning' : 'success'), + Tables\Columns\TextColumn::make('subscription_tier') + ->badge() + ->color(fn (string $state): string => match($state) { + 'free' => 'gray', + 'starter' => 'info', + 'pro' => 'success', + 'agency' => 'warning', + 'lifetime' => 'danger', + }), + Tables\Columns\TextColumn::make('total_revenue') + ->money('EUR') + ->sortable(), + Tables\Columns\IconColumn::make('is_active') + ->boolean() + ->color(fn (bool $state): string => $state ? 'success' : 'danger'), Tables\Columns\TextColumn::make('last_activity_at')->since()->label(__('admin.common.last_activity')), Tables\Columns\TextColumn::make('created_at')->dateTime()->toggleable(isToggledHiddenByDefault: true), ]) ->filters([]) ->actions([ Actions\EditAction::make(), + Actions\Action::make('add_credits') + ->label('Credits hinzufügen') + ->icon('heroicon-o-plus') + ->form([ + Forms\Components\TextInput::make('credits')->numeric()->required()->minValue(1), + Forms\Components\Textarea::make('reason')->label('Grund')->rows(3), + ]) + ->action(function (Tenant $record, array $data) { + $record->increment('event_credits_balance', $data['credits']); + \App\Models\EventPurchase::create([ + 'tenant_id' => $record->id, + 'package_id' => 'manual_adjustment', + 'credits_added' => $data['credits'], + 'price' => 0, + 'platform' => 'manual', + 'transaction_id' => null, + 'reason' => $data['reason'], + ]); + }), + Actions\Action::make('suspend') + ->label('Suspendieren') + ->color('danger') + ->requiresConfirmation() + ->action(fn (Tenant $record) => $record->update(['is_suspended' => true])), + Actions\Action::make('export') + ->label('Daten exportieren') + ->icon('heroicon-o-arrow-down-tray') + ->url(fn (Tenant $record) => route('admin.tenants.export', $record)) + ->openUrlInNewTab(), ]) ->bulkActions([ Actions\DeleteBulkAction::make(), diff --git a/app/Filament/Resources/TenantResource/RelationManagers/PurchasesRelationManager.php b/app/Filament/Resources/TenantResource/RelationManagers/PurchasesRelationManager.php new file mode 100644 index 0000000..8f56350 --- /dev/null +++ b/app/Filament/Resources/TenantResource/RelationManagers/PurchasesRelationManager.php @@ -0,0 +1,128 @@ +schema([ + Select::make('package_id') + ->label('Paket') + ->options([ + 'starter_pack' => 'Starter Pack (5 Events)', + 'pro_pack' => 'Pro Pack (20 Events)', + 'lifetime_unlimited' => 'Lifetime Unlimited', + 'monthly_pro' => 'Pro Subscription', + 'monthly_agency' => 'Agency Subscription', + ]) + ->required(), + TextInput::make('credits_added') + ->label('Credits hinzugefügt') + ->numeric() + ->required() + ->minValue(0), + TextInput::make('price') + ->label('Preis') + ->money('EUR') + ->required(), + Select::make('platform') + ->label('Plattform') + ->options([ + 'ios' => 'iOS', + 'android' => 'Android', + 'web' => 'Web', + 'manual' => 'Manuell', + ]) + ->required(), + TextInput::make('transaction_id') + ->label('Transaktions-ID') + ->maxLength(255), + Textarea::make('reason') + ->label('Beschreibung') + ->maxLength(65535) + ->columnSpanFull(), + ]) + ->columns(2); + } + + public function table(Table $table): Table + { + return $table + ->recordTitleAttribute('package_id') + ->columns([ + TextColumn::make('package_id') + ->badge() + ->color(fn (string $state): string => match($state) { + 'starter_pack' => 'info', + 'pro_pack' => 'success', + 'lifetime_unlimited' => 'danger', + 'monthly_pro' => 'warning', + default => 'gray', + }), + TextColumn::make('credits_added') + ->label('Credits') + ->badge() + ->color('success'), + TextColumn::make('price') + ->money('EUR'), + TextColumn::make('platform') + ->badge() + ->color(fn (string $state): string => match($state) { + 'ios' => 'info', + 'android' => 'success', + 'web' => 'warning', + 'manual' => 'gray', + }), + TextColumn::make('purchased_at') + ->dateTime() + ->sortable(), + TextColumn::make('transaction_id') + ->copyable() + ->toggleable(), + ]) + ->filters([ + SelectFilter::make('platform') + ->options(['ios' => 'iOS', 'android' => 'Android', 'web' => 'Web', 'manual' => 'Manuell']), + SelectFilter::make('package_id') + ->options([ + 'starter_pack' => 'Starter Pack', + 'pro_pack' => 'Pro Pack', + 'lifetime_unlimited' => 'Lifetime', + 'monthly_pro' => 'Pro Subscription', + 'monthly_agency' => 'Agency Subscription', + ]), + ]) + ->headerActions([]) + ->actions([ + ViewAction::make(), + ]) + ->bulkActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Api/Tenant/EventController.php b/app/Http/Controllers/Api/Tenant/EventController.php new file mode 100644 index 0000000..cdb7420 --- /dev/null +++ b/app/Http/Controllers/Api/Tenant/EventController.php @@ -0,0 +1,224 @@ +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); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Api/Tenant/PhotoController.php b/app/Http/Controllers/Api/Tenant/PhotoController.php new file mode 100644 index 0000000..201166f --- /dev/null +++ b/app/Http/Controllers/Api/Tenant/PhotoController.php @@ -0,0 +1,470 @@ +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); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Api/Tenant/SettingsController.php b/app/Http/Controllers/Api/Tenant/SettingsController.php new file mode 100644 index 0000000..1d72f17 --- /dev/null +++ b/app/Http/Controllers/Api/Tenant/SettingsController.php @@ -0,0 +1,135 @@ +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.', + ]); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Api/Tenant/TaskController.php b/app/Http/Controllers/Api/Tenant/TaskController.php new file mode 100644 index 0000000..122fbde --- /dev/null +++ b/app/Http/Controllers/Api/Tenant/TaskController.php @@ -0,0 +1,242 @@ +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); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/OAuthController.php b/app/Http/Controllers/OAuthController.php new file mode 100644 index 0000000..0150d91 --- /dev/null +++ b/app/Http/Controllers/OAuthController.php @@ -0,0 +1,376 @@ +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()); + } + } +} diff --git a/app/Http/Controllers/StripeWebhookController.php b/app/Http/Controllers/StripeWebhookController.php new file mode 100644 index 0000000..52965c5 --- /dev/null +++ b/app/Http/Controllers/StripeWebhookController.php @@ -0,0 +1,91 @@ +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); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Tenant/CreditController.php b/app/Http/Controllers/Tenant/CreditController.php new file mode 100644 index 0000000..95504c8 --- /dev/null +++ b/app/Http/Controllers/Tenant/CreditController.php @@ -0,0 +1,59 @@ +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); + } +} \ No newline at end of file diff --git a/app/Http/Middleware/CreditCheckMiddleware.php b/app/Http/Middleware/CreditCheckMiddleware.php new file mode 100644 index 0000000..4b7ac59 --- /dev/null +++ b/app/Http/Middleware/CreditCheckMiddleware.php @@ -0,0 +1,39 @@ +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); + } +} \ No newline at end of file diff --git a/app/Http/Middleware/TenantIsolation.php b/app/Http/Middleware/TenantIsolation.php new file mode 100644 index 0000000..b4a7e86 --- /dev/null +++ b/app/Http/Middleware/TenantIsolation.php @@ -0,0 +1,61 @@ +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; + } +} \ No newline at end of file diff --git a/app/Http/Middleware/TenantTokenGuard.php b/app/Http/Middleware/TenantTokenGuard.php new file mode 100644 index 0000000..f434199 --- /dev/null +++ b/app/Http/Middleware/TenantTokenGuard.php @@ -0,0 +1,162 @@ +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; + } +} \ No newline at end of file diff --git a/app/Http/Requests/Tenant/EventStoreRequest.php b/app/Http/Requests/Tenant/EventStoreRequest.php new file mode 100644 index 0000000..f4a601f --- /dev/null +++ b/app/Http/Requests/Tenant/EventStoreRequest.php @@ -0,0 +1,70 @@ +|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'), + ]); + } +} \ No newline at end of file diff --git a/app/Http/Requests/Tenant/PhotoStoreRequest.php b/app/Http/Requests/Tenant/PhotoStoreRequest.php new file mode 100644 index 0000000..27ff0e5 --- /dev/null +++ b/app/Http/Requests/Tenant/PhotoStoreRequest.php @@ -0,0 +1,62 @@ + + */ + 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) : [], + ]); + } +} \ No newline at end of file diff --git a/app/Http/Requests/Tenant/SettingsStoreRequest.php b/app/Http/Requests/Tenant/SettingsStoreRequest.php new file mode 100644 index 0000000..3118846 --- /dev/null +++ b/app/Http/Requests/Tenant/SettingsStoreRequest.php @@ -0,0 +1,66 @@ + + */ + 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', []), + ]); + } +} \ No newline at end of file diff --git a/app/Http/Requests/Tenant/TaskStoreRequest.php b/app/Http/Requests/Tenant/TaskStoreRequest.php new file mode 100644 index 0000000..22983fb --- /dev/null +++ b/app/Http/Requests/Tenant/TaskStoreRequest.php @@ -0,0 +1,60 @@ + + */ + 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.', + ]; + } +} \ No newline at end of file diff --git a/app/Http/Requests/Tenant/TaskUpdateRequest.php b/app/Http/Requests/Tenant/TaskUpdateRequest.php new file mode 100644 index 0000000..871db13 --- /dev/null +++ b/app/Http/Requests/Tenant/TaskUpdateRequest.php @@ -0,0 +1,59 @@ + + */ + 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.', + ]; + } +} \ No newline at end of file diff --git a/app/Http/Resources/Tenant/CreditLedgerResource.php b/app/Http/Resources/Tenant/CreditLedgerResource.php new file mode 100644 index 0000000..d12219c --- /dev/null +++ b/app/Http/Resources/Tenant/CreditLedgerResource.php @@ -0,0 +1,21 @@ + $this->id, + 'delta' => $this->delta, + 'reason' => $this->reason, + 'note' => $this->note, + 'related_purchase_id' => $this->related_purchase_id, + 'created_at' => $this->created_at->toISOString(), + ]; + } +} \ No newline at end of file diff --git a/app/Http/Resources/Tenant/EventPurchaseResource.php b/app/Http/Resources/Tenant/EventPurchaseResource.php new file mode 100644 index 0000000..1416c48 --- /dev/null +++ b/app/Http/Resources/Tenant/EventPurchaseResource.php @@ -0,0 +1,24 @@ + $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(), + ]; + } +} \ No newline at end of file diff --git a/app/Http/Resources/Tenant/EventResource.php b/app/Http/Resources/Tenant/EventResource.php new file mode 100644 index 0000000..79fb97c --- /dev/null +++ b/app/Http/Resources/Tenant/EventResource.php @@ -0,0 +1,56 @@ + + */ + 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, + ]; + } +} \ No newline at end of file diff --git a/app/Http/Resources/Tenant/EventTypeResource.php b/app/Http/Resources/Tenant/EventTypeResource.php new file mode 100644 index 0000000..2d51cf0 --- /dev/null +++ b/app/Http/Resources/Tenant/EventTypeResource.php @@ -0,0 +1,28 @@ + + */ + 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(), + ]; + } +} \ No newline at end of file diff --git a/app/Http/Resources/Tenant/PhotoResource.php b/app/Http/Resources/Tenant/PhotoResource.php new file mode 100644 index 0000000..cbf2c30 --- /dev/null +++ b/app/Http/Resources/Tenant/PhotoResource.php @@ -0,0 +1,58 @@ + + */ + 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}"); + } +} \ No newline at end of file diff --git a/app/Http/Resources/Tenant/TaskResource.php b/app/Http/Resources/Tenant/TaskResource.php new file mode 100644 index 0000000..116d750 --- /dev/null +++ b/app/Http/Resources/Tenant/TaskResource.php @@ -0,0 +1,42 @@ + + */ + 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(), + ]; + } +} \ No newline at end of file diff --git a/app/Jobs/ValidateStripeWebhookJob.php b/app/Jobs/ValidateStripeWebhookJob.php new file mode 100644 index 0000000..62a9f6a --- /dev/null +++ b/app/Jobs/ValidateStripeWebhookJob.php @@ -0,0 +1,99 @@ +payload = $payload; + $this->sig = $sig; + } + + public function handle() + { + $secret = config('services.stripe.webhook'); + + if (!$secret) { + Log::error('No Stripe webhook secret configured'); + return; + } + + $expectedSig = 'v1=' . hash_hmac('sha256', $this->payload, $secret); + + if (!hash_equals($expectedSig, $this->sig)) { + Log::error('Invalid signature in Stripe webhook job'); + return; + } + + $event = json_decode($this->payload, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + Log::error('Invalid JSON in Stripe webhook job: ' . json_last_error_msg()); + return; + } + + if ($event['type'] === 'checkout.session.completed') { + $session = $event['data']['object']; + $receiptId = $session['id']; + + if (EventPurchase::where('external_receipt_id', $receiptId)->exists()) { + return; + } + + $tenantId = $session['metadata']['tenant_id'] ?? null; + + if (!$tenantId) { + Log::warning('No tenant_id in Stripe metadata job', ['receipt_id' => $receiptId]); + return; + } + + $tenant = Tenant::find($tenantId); + + if (!$tenant) { + Log::error('Tenant not found in Stripe webhook job', ['tenant_id' => $tenantId]); + return; + } + + $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 via job', ['receipt_id' => $receiptId, 'tenant_id' => $tenantId]); + } else { + Log::info('Unhandled Stripe event in job', ['type' => $event['type']]); + } + } +} \ No newline at end of file diff --git a/app/Models/Event.php b/app/Models/Event.php index eed1e58..b16f5b4 100644 --- a/app/Models/Event.php +++ b/app/Models/Event.php @@ -2,16 +2,20 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Relations\BelongsTo; -use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\{BelongsTo, HasMany, BelongsToMany}; class Event extends Model { + use HasFactory; + protected $table = 'events'; protected $guarded = []; protected $casts = [ 'date' => 'date', + 'name' => 'array', + 'description' => 'array', 'settings' => 'array', 'is_active' => 'boolean', ]; @@ -25,5 +29,15 @@ class Event extends Model { return $this->hasMany(Photo::class); } + + public function taskCollections(): BelongsToMany + { + return $this->belongsToMany( + TaskCollection::class, + 'event_task_collection', + 'event_id', + 'task_collection_id' + ); + } } diff --git a/app/Models/EventCreditsLedger.php b/app/Models/EventCreditsLedger.php new file mode 100644 index 0000000..381bbf9 --- /dev/null +++ b/app/Models/EventCreditsLedger.php @@ -0,0 +1,29 @@ + 'datetime', + 'delta' => 'integer', + ]; + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + public function relatedPurchase(): BelongsTo + { + return $this->belongsTo(EventPurchase::class, 'related_purchase_id'); + } +} \ No newline at end of file diff --git a/app/Models/EventPurchase.php b/app/Models/EventPurchase.php new file mode 100644 index 0000000..7d98bc4 --- /dev/null +++ b/app/Models/EventPurchase.php @@ -0,0 +1,24 @@ + 'datetime', + 'amount' => 'decimal:2', + ]; + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } +} \ No newline at end of file diff --git a/app/Models/OAuthClient.php b/app/Models/OAuthClient.php new file mode 100644 index 0000000..5cd851d --- /dev/null +++ b/app/Models/OAuthClient.php @@ -0,0 +1,27 @@ + 'array', + 'redirect_uris' => 'array', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; +} diff --git a/app/Models/OAuthCode.php b/app/Models/OAuthCode.php new file mode 100644 index 0000000..ee47922 --- /dev/null +++ b/app/Models/OAuthCode.php @@ -0,0 +1,45 @@ + 'datetime', + 'created_at' => 'datetime', + ]; + + public function client(): BelongsTo + { + return $this->belongsTo(OAuthClient::class, 'client_id', 'client_id'); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function isExpired(): bool + { + return $this->expires_at < now(); + } +} diff --git a/app/Models/PurchaseHistory.php b/app/Models/PurchaseHistory.php new file mode 100644 index 0000000..0534c87 --- /dev/null +++ b/app/Models/PurchaseHistory.php @@ -0,0 +1,37 @@ + 'integer', + 'price' => 'decimal:2', + 'purchased_at' => 'datetime', + 'created_at' => 'datetime', + ]; + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } +} diff --git a/app/Models/RefreshToken.php b/app/Models/RefreshToken.php new file mode 100644 index 0000000..aa3980f --- /dev/null +++ b/app/Models/RefreshToken.php @@ -0,0 +1,56 @@ + 'datetime', + 'revoked_at' => 'datetime', + 'created_at' => 'datetime', + ]; + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + public function revoke(): bool + { + return $this->update(['revoked_at' => now()]); + } + + public function isActive(): bool + { + return $this->revoked_at === null && $this->expires_at > now(); + } + + public function scopeActive($query) + { + return $query->whereNull('revoked_at')->where('expires_at', '>', now()); + } + + public function scopeForTenant($query, string $tenantId) + { + return $query->where('tenant_id', $tenantId); + } +} diff --git a/app/Models/Task.php b/app/Models/Task.php index fbccc56..920a943 100644 --- a/app/Models/Task.php +++ b/app/Models/Task.php @@ -2,11 +2,15 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; class Task extends Model { + use HasFactory; + protected $table = 'tasks'; protected $guarded = []; protected $casts = [ @@ -24,4 +28,15 @@ class Task extends Model { return $this->belongsTo(EventType::class, 'event_type_id'); } + + public function taskCollection(): BelongsTo + { + return $this->belongsTo(TaskCollection::class, 'collection_id'); + } + + public function assignedEvents(): BelongsToMany + { + return $this->belongsToMany(Event::class, 'event_task', 'task_id', 'event_id') + ->withTimestamps(); + } } diff --git a/app/Models/TaskCollection.php b/app/Models/TaskCollection.php index ac76712..be115d6 100644 --- a/app/Models/TaskCollection.php +++ b/app/Models/TaskCollection.php @@ -2,14 +2,18 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsToMany; class TaskCollection extends Model { + use HasFactory; + protected $table = 'task_collections'; protected $fillable = [ + 'tenant_id', 'name', 'description', ]; @@ -25,11 +29,11 @@ class TaskCollection extends Model public function tasks(): BelongsToMany { return $this->belongsToMany( - Task::class, - 'task_collection_task', - 'task_collection_id', + Task::class, + 'task_collection_task', + 'task_collection_id', 'task_id' - )->withTimestamps(); + ); } /** @@ -38,11 +42,11 @@ class TaskCollection extends Model public function events(): BelongsToMany { return $this->belongsToMany( - Event::class, - 'event_task_collection', - 'task_collection_id', + Event::class, + 'event_task_collection', + 'task_collection_id', 'event_id' - )->withTimestamps(); + ); } /** diff --git a/app/Models/Tenant.php b/app/Models/Tenant.php index 25332dd..208580e 100644 --- a/app/Models/Tenant.php +++ b/app/Models/Tenant.php @@ -2,17 +2,30 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasManyThrough; +use Illuminate\Database\Eloquent\Casts\Attribute; +use Illuminate\Support\Facades\DB; +use App\Models\EventPurchase; +use App\Models\EventCreditsLedger; class Tenant extends Model { + use HasFactory; + protected $table = 'tenants'; protected $guarded = []; protected $casts = [ + 'name' => 'array', + 'settings' => 'array', 'features' => 'array', 'last_activity_at' => 'datetime', + 'event_credits_balance' => 'integer', + 'subscription_tier' => 'string', + 'subscription_expires_at' => 'datetime', + 'total_revenue' => 'decimal:2', ]; public function events(): HasMany @@ -31,4 +44,60 @@ class Tenant extends Model 'id' // Local key on events table... ); } + + public function purchases(): HasMany + { + return $this->hasMany(PurchaseHistory::class); + } + + public function eventPurchases(): HasMany + { + return $this->hasMany(EventPurchase::class); + } + + public function creditsLedger(): HasMany + { + return $this->hasMany(EventCreditsLedger::class); + } + + public function activeSubscription(): Attribute + { + return Attribute::make( + get: fn () => $this->subscription_expires_at && $this->subscription_expires_at->isFuture(), + ); + } + + public function decrementCredits(int $amount, string $reason = 'event_create', ?string $note = null, ?int $relatedPurchaseId = null): bool + { + if ($this->event_credits_balance < $amount) { + return false; + } + + return DB::transaction(function () use ($amount, $reason, $note, $relatedPurchaseId) { + EventCreditsLedger::create([ + 'tenant_id' => $this->id, + 'delta' => -$amount, + 'reason' => $reason, + 'related_purchase_id' => $relatedPurchaseId, + 'note' => $note, + ]); + + return $this->decrement('event_credits_balance', $amount); + }); + } + + public function incrementCredits(int $amount, string $reason = 'manual_adjust', ?string $note = null, ?int $relatedPurchaseId = null): bool + { + return DB::transaction(function () use ($amount, $reason, $note, $relatedPurchaseId) { + EventCreditsLedger::create([ + 'tenant_id' => $this->id, + 'delta' => $amount, + 'reason' => $reason, + 'related_purchase_id' => $relatedPurchaseId, + 'note' => $note, + ]); + + return $this->increment('event_credits_balance', $amount); + }); + } } diff --git a/app/Models/TenantToken.php b/app/Models/TenantToken.php new file mode 100644 index 0000000..b419a3a --- /dev/null +++ b/app/Models/TenantToken.php @@ -0,0 +1,47 @@ + 'datetime', + 'revoked_at' => 'datetime', + 'created_at' => 'datetime', + ]; + + public function scopeActive($query) + { + return $query->whereNull('revoked_at')->where('expires_at', '>', now()); + } + + public function scopeForTenant($query, string $tenantId) + { + return $query->where('tenant_id', $tenantId); + } + + public function revoke(): bool + { + return $this->update(['revoked_at' => now()]); + } + + public function isActive(): bool + { + return $this->revoked_at === null && $this->expires_at > now(); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index f3f97ea..a68d373 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -7,11 +7,12 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Laravel\Sanctum\HasApiTokens; class User extends Authenticatable { /** @use HasFactory<\Database\Factories\UserFactory> */ - use HasFactory, Notifiable; + use HasApiTokens, HasFactory, Notifiable; /** * The attributes that are mass assignable. diff --git a/bootstrap/app.php b/bootstrap/app.php index 2ebd12b..54a9fa6 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -24,6 +24,12 @@ return Application::configure(basePath: dirname(__DIR__)) HandleInertiaRequests::class, AddLinkHeadersForPreloadedAssets::class, ]); + + $middleware->api(append: [ + \App\Http\Middleware\TenantTokenGuard::class, + \App\Http\Middleware\TenantIsolation::class, + \App\Http\Middleware\CreditCheckMiddleware::class, + ]); }) ->withExceptions(function (Exceptions $exceptions) { // diff --git a/composer.json b/composer.json index 0858982..365a733 100644 --- a/composer.json +++ b/composer.json @@ -8,11 +8,14 @@ "require": { "php": "^8.2", "filament/filament": "~4.0", + "firebase/php-jwt": "^6.11", "inertiajs/inertia-laravel": "^2.0", "laravel/framework": "^12.0", + "laravel/sanctum": "^4.2", "laravel/tinker": "^2.10.1", "laravel/wayfinder": "^0.1.9", - "simplesoftwareio/simple-qrcode": "^4.2" + "simplesoftwareio/simple-qrcode": "^4.2", + "stripe/stripe-php": "^17.6" }, "require-dev": { "fakerphp/faker": "^1.23", diff --git a/composer.lock b/composer.lock index 96af81d..35605cc 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "0c9db03df0ece5b657d93996d9b13320", + "content-hash": "855707cf018e10451bf627dbd6593f0a", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -1533,6 +1533,69 @@ }, "time": "2025-09-04T14:12:52+00:00" }, + { + "name": "firebase/php-jwt", + "version": "v6.11.1", + "source": { + "type": "git", + "url": "https://github.com/firebase/php-jwt.git", + "reference": "d1e91ecf8c598d073d0995afa8cd5c75c6e19e66" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/firebase/php-jwt/zipball/d1e91ecf8c598d073d0995afa8cd5c75c6e19e66", + "reference": "d1e91ecf8c598d073d0995afa8cd5c75c6e19e66", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.4", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psr/cache": "^2.0||^3.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0" + }, + "suggest": { + "ext-sodium": "Support EdDSA (Ed25519) signatures", + "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present" + }, + "type": "library", + "autoload": { + "psr-4": { + "Firebase\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Neuman Vong", + "email": "neuman+pear@twilio.com", + "role": "Developer" + }, + { + "name": "Anant Narayanan", + "email": "anant@php.net", + "role": "Developer" + } + ], + "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", + "homepage": "https://github.com/firebase/php-jwt", + "keywords": [ + "jwt", + "php" + ], + "support": { + "issues": "https://github.com/firebase/php-jwt/issues", + "source": "https://github.com/firebase/php-jwt/tree/v6.11.1" + }, + "time": "2025-04-09T20:32:01+00:00" + }, { "name": "fruitcake/php-cors", "version": "v1.3.0", @@ -2488,6 +2551,70 @@ }, "time": "2025-07-07T14:17:42+00:00" }, + { + "name": "laravel/sanctum", + "version": "v4.2.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/sanctum.git", + "reference": "fd6df4f79f48a72992e8d29a9c0ee25422a0d677" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/fd6df4f79f48a72992e8d29a9c0ee25422a0d677", + "reference": "fd6df4f79f48a72992e8d29a9c0ee25422a0d677", + "shasum": "" + }, + "require": { + "ext-json": "*", + "illuminate/console": "^11.0|^12.0", + "illuminate/contracts": "^11.0|^12.0", + "illuminate/database": "^11.0|^12.0", + "illuminate/support": "^11.0|^12.0", + "php": "^8.2", + "symfony/console": "^7.0" + }, + "require-dev": { + "mockery/mockery": "^1.6", + "orchestra/testbench": "^9.0|^10.0", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Sanctum\\SanctumServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Sanctum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.", + "keywords": [ + "auth", + "laravel", + "sanctum" + ], + "support": { + "issues": "https://github.com/laravel/sanctum/issues", + "source": "https://github.com/laravel/sanctum" + }, + "time": "2025-07-09T19:45:24+00:00" + }, { "name": "laravel/serializable-closure", "version": "v2.0.4", @@ -5571,6 +5698,65 @@ ], "time": "2025-02-21T14:16:57+00:00" }, + { + "name": "stripe/stripe-php", + "version": "v17.6.0", + "source": { + "type": "git", + "url": "https://github.com/stripe/stripe-php.git", + "reference": "a6219df5df1324a0d3f1da25fb5e4b8a3307ea16" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/stripe/stripe-php/zipball/a6219df5df1324a0d3f1da25fb5e4b8a3307ea16", + "reference": "a6219df5df1324a0d3f1da25fb5e4b8a3307ea16", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "ext-mbstring": "*", + "php": ">=5.6.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "3.72.0", + "phpstan/phpstan": "^1.2", + "phpunit/phpunit": "^5.7 || ^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "Stripe\\": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Stripe and contributors", + "homepage": "https://github.com/stripe/stripe-php/contributors" + } + ], + "description": "Stripe PHP Library", + "homepage": "https://stripe.com/", + "keywords": [ + "api", + "payment processing", + "stripe" + ], + "support": { + "issues": "https://github.com/stripe/stripe-php/issues", + "source": "https://github.com/stripe/stripe-php/tree/v17.6.0" + }, + "time": "2025-08-27T19:32:42+00:00" + }, { "name": "symfony/clock", "version": "v7.3.0", diff --git a/config/auth.php b/config/auth.php index 0ba5d5d..004d7f6 100644 --- a/config/auth.php +++ b/config/auth.php @@ -40,6 +40,10 @@ return [ 'driver' => 'session', 'provider' => 'users', ], + 'api' => [ + 'driver' => 'sanctum', + 'provider' => 'users', + ], ], /* diff --git a/config/sanctum.php b/config/sanctum.php new file mode 100644 index 0000000..44527d6 --- /dev/null +++ b/config/sanctum.php @@ -0,0 +1,84 @@ + explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf( + '%s%s', + 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1', + Sanctum::currentApplicationUrlWithPort(), + // Sanctum::currentRequestHost(), + ))), + + /* + |-------------------------------------------------------------------------- + | Sanctum Guards + |-------------------------------------------------------------------------- + | + | This array contains the authentication guards that will be checked when + | Sanctum is trying to authenticate a request. If none of these guards + | are able to authenticate the request, Sanctum will use the bearer + | token that's present on an incoming request for authentication. + | + */ + + 'guard' => ['web'], + + /* + |-------------------------------------------------------------------------- + | Expiration Minutes + |-------------------------------------------------------------------------- + | + | This value controls the number of minutes until an issued token will be + | considered expired. This will override any values set in the token's + | "expires_at" attribute, but first-party sessions are not affected. + | + */ + + 'expiration' => null, + + /* + |-------------------------------------------------------------------------- + | Token Prefix + |-------------------------------------------------------------------------- + | + | Sanctum can prefix new tokens in order to take advantage of numerous + | security scanning initiatives maintained by open source platforms + | that notify developers if they commit tokens into repositories. + | + | See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning + | + */ + + 'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''), + + /* + |-------------------------------------------------------------------------- + | Sanctum Middleware + |-------------------------------------------------------------------------- + | + | When authenticating your first-party SPA with Sanctum you may need to + | customize some of the middleware Sanctum uses while processing the + | request. You may change the middleware listed below as required. + | + */ + + 'middleware' => [ + 'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class, + 'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class, + 'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class, + ], + +]; diff --git a/config/services.php b/config/services.php index 27a3617..fe447f0 100644 --- a/config/services.php +++ b/config/services.php @@ -35,4 +35,9 @@ return [ ], ], + 'stripe' => [ + 'key' => env('STRIPE_KEY'), + 'secret' => env('STRIPE_SECRET'), + 'webhook' => env('STRIPE_WEBHOOK_SECRET'), + ], ]; diff --git a/database/factories/EventFactory.php b/database/factories/EventFactory.php new file mode 100644 index 0000000..f5920cd --- /dev/null +++ b/database/factories/EventFactory.php @@ -0,0 +1,63 @@ +faker->words(3, true); + $slug = Str::slug($name); + + return [ + 'tenant_id' => Tenant::factory(), + 'name' => $name, + 'slug' => $slug, + 'description' => $this->faker->paragraph(), + 'date' => $this->faker->dateTimeBetween('now', '+6 months'), + 'location' => $this->faker->address(), + 'max_participants' => $this->faker->numberBetween(50, 500), + 'is_active' => true, + 'join_link_enabled' => true, + 'photo_upload_enabled' => true, + 'task_checklist_enabled' => true, + ]; + } + + public function inactive(): static + { + return $this->state(fn (array $attributes) => [ + 'is_active' => false, + ]); + } + + public function withTasks(int $count = 3): static + { + return $this->afterCreating(function (Event $event) use ($count) { + $event->tasks()->attach( + \App\Models\Task::factory($count)->create(['tenant_id' => $event->tenant_id]) + ); + }); + } + + public function past(): static + { + return $this->state(fn (array $attributes) => [ + 'date' => $this->faker->dateTimeBetween('-1 month', 'now'), + ]); + } + + public function upcoming(): static + { + return $this->state(fn (array $attributes) => [ + 'date' => $this->faker->dateTimeBetween('now', '+1 month'), + ]); + } +} \ No newline at end of file diff --git a/database/factories/TaskCollectionFactory.php b/database/factories/TaskCollectionFactory.php new file mode 100644 index 0000000..5c1d0f5 --- /dev/null +++ b/database/factories/TaskCollectionFactory.php @@ -0,0 +1,46 @@ + Tenant::factory(), + 'name' => $this->faker->randomElement($categories), + 'description' => $this->faker->sentence(), + 'is_default' => $this->faker->boolean(20), + 'position' => $this->faker->numberBetween(1, 10), + ]; + } + + public function withTasks(int $count = 3): static + { + return $this->afterCreating(function (TaskCollection $collection) use ($count) { + \App\Models\Task::factory($count) + ->create(['tenant_id' => $collection->tenant_id]) + ->each(function ($task) use ($collection) { + $task->taskCollection()->associate($collection); + $task->save(); + }); + }); + } + + public function default(): static + { + return $this->state(fn (array $attributes) => [ + 'is_default' => true, + 'position' => 1, + ]); + } +} \ No newline at end of file diff --git a/database/factories/TaskFactory.php b/database/factories/TaskFactory.php new file mode 100644 index 0000000..6d99f0c --- /dev/null +++ b/database/factories/TaskFactory.php @@ -0,0 +1,56 @@ + Tenant::factory(), + 'title' => $this->faker->sentence(4), + 'description' => $this->faker->paragraph(), + 'due_date' => $this->faker->dateTimeBetween('now', '+1 month'), + 'is_completed' => $this->faker->boolean(20), // 20% chance completed + 'collection_id' => null, + ]; + } + + public function completed(): static + { + return $this->state(fn (array $attributes) => [ + 'is_completed' => true, + 'completed_at' => now(), + ]); + } + + public function urgent(): static + { + return $this->state(fn (array $attributes) => [ + 'priority' => 'urgent', + 'due_date' => $this->faker->dateTimeBetween('now', '+3 days'), + ]); + } + + public function withCollection(): static + { + return $this->state(fn (array $attributes) => [ + 'collection_id' => TaskCollection::factory(), + ]); + } + + public function assignedToEvent(): static + { + return $this->afterCreating(function (Task $task) { + $event = \App\Models\Event::factory()->create(['tenant_id' => $task->tenant_id]); + $task->assignedEvents()->attach($event); + }); + } +} \ No newline at end of file diff --git a/database/factories/TenantFactory.php b/database/factories/TenantFactory.php new file mode 100644 index 0000000..d3653f2 --- /dev/null +++ b/database/factories/TenantFactory.php @@ -0,0 +1,66 @@ +faker->company(); + $slug = Str::slug($name); + + return [ + 'name' => $name, + 'slug' => $slug, + 'contact_email' => $this->faker->companyEmail(), + 'event_credits_balance' => $this->faker->numberBetween(1, 20), + 'subscription_tier' => $this->faker->randomElement(['free', 'starter', 'pro']), + 'subscription_expires_at' => $this->faker->dateTimeBetween('now', '+1 year'), + 'is_active' => true, + 'is_suspended' => false, + 'settings' => json_encode([ + '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, + ]), + 'settings_updated_at' => now(), + ]; + } + + public function suspended(): static + { + return $this->state(fn (array $attributes) => [ + 'is_suspended' => true, + ]); + } + + public function inactive(): static + { + return $this->state(fn (array $attributes) => [ + 'is_active' => false, + ]); + } + + public function withLowCredits(): static + { + return $this->state(fn (array $attributes) => [ + 'event_credits_balance' => 1, + ]); + } +} \ No newline at end of file diff --git a/database/migrations/2025_09_01_100200_create_events_table.php b/database/migrations/2025_09_01_100200_create_events_table.php index 83aa704..8c462bc 100644 --- a/database/migrations/2025_09_01_100200_create_events_table.php +++ b/database/migrations/2025_09_01_100200_create_events_table.php @@ -9,15 +9,23 @@ return new class extends Migration { { Schema::create('events', function (Blueprint $table) { $table->id(); - $table->json('name'); - $table->json('description')->nullable(); - $table->date('date'); + $table->foreignId('tenant_id')->constrained()->onDelete('cascade'); + $table->string('name'); + $table->text('description')->nullable(); + $table->dateTime('date'); $table->string('slug')->unique(); + $table->string('location')->nullable(); + $table->integer('max_participants')->nullable(); $table->json('settings')->nullable(); $table->unsignedBigInteger('event_type_id'); $table->boolean('is_active')->default(true); + $table->boolean('join_link_enabled')->default(true); + $table->boolean('photo_upload_enabled')->default(true); + $table->boolean('task_checklist_enabled')->default(true); $table->string('default_locale', 5)->default('de'); $table->timestamps(); + + $table->index(['tenant_id', 'date', 'is_active']); }); } diff --git a/database/migrations/2025_09_01_100300_create_tasks_table.php b/database/migrations/2025_09_01_100300_create_tasks_table.php index f4297c0..b3ee94a 100644 --- a/database/migrations/2025_09_01_100300_create_tasks_table.php +++ b/database/migrations/2025_09_01_100300_create_tasks_table.php @@ -9,15 +9,23 @@ return new class extends Migration { { Schema::create('tasks', function (Blueprint $table) { $table->id(); - $table->unsignedBigInteger('emotion_id'); + $table->foreignId('tenant_id')->constrained()->onDelete('cascade'); + $table->unsignedBigInteger('emotion_id')->nullable(); $table->unsignedBigInteger('event_type_id')->nullable(); - $table->json('title'); - $table->json('description'); - $table->json('example_text')->nullable(); + $table->string('title'); + $table->text('description')->nullable(); + $table->text('example_text')->nullable(); + $table->dateTime('due_date')->nullable(); + $table->boolean('is_completed')->default(false); + $table->enum('priority', ['low', 'medium', 'high', 'urgent'])->default('medium'); + $table->unsignedBigInteger('collection_id')->nullable(); $table->enum('difficulty', ['easy','medium','hard'])->default('easy'); $table->integer('sort_order')->default(0); $table->boolean('is_active')->default(true); $table->timestamps(); + + $table->foreign('collection_id')->references('id')->on('task_collections')->onDelete('set null'); + $table->index(['tenant_id', 'is_completed', 'priority']); }); } diff --git a/database/migrations/2025_09_03_122009_create_task_collections_tables.php b/database/migrations/2025_09_03_122009_create_task_collections_tables.php index bd790c2..da42b43 100644 --- a/database/migrations/2025_09_03_122009_create_task_collections_tables.php +++ b/database/migrations/2025_09_03_122009_create_task_collections_tables.php @@ -9,15 +9,21 @@ return new class extends Migration { { Schema::create('task_collections', function (Blueprint $table) { $table->id(); - $table->json('name'); - $table->json('description')->nullable(); + $table->foreignId('tenant_id')->constrained()->onDelete('cascade'); + $table->string('name'); + $table->text('description')->nullable(); + $table->boolean('is_default')->default(false); + $table->integer('position')->default(0); $table->timestamps(); + + $table->index(['tenant_id', 'is_default', 'position']); }); Schema::create('task_collection_task', function (Blueprint $table) { - $table->unsignedBigInteger('task_collection_id'); - $table->unsignedBigInteger('task_id'); + $table->foreignId('task_collection_id')->constrained()->onDelete('cascade'); + $table->foreignId('task_id')->constrained()->onDelete('cascade'); $table->primary(['task_collection_id','task_id']); + $table->integer('sort_order')->default(0); }); } diff --git a/database/migrations/2025_09_12_095200_create_event_task_table.php b/database/migrations/2025_09_12_095200_create_event_task_table.php new file mode 100644 index 0000000..17caab7 --- /dev/null +++ b/database/migrations/2025_09_12_095200_create_event_task_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('event_id')->constrained()->onDelete('cascade'); + $table->foreignId('task_id')->constrained()->onDelete('cascade'); + $table->integer('sort_order')->default(0); + $table->timestamps(); + + $table->unique(['event_id', 'task_id']); + $table->index(['event_id', 'task_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('event_task'); + } +}; \ No newline at end of file diff --git a/database/migrations/2025_09_15_123506_create_oauth_clients_table.php b/database/migrations/2025_09_15_123506_create_oauth_clients_table.php new file mode 100644 index 0000000..5f56841 --- /dev/null +++ b/database/migrations/2025_09_15_123506_create_oauth_clients_table.php @@ -0,0 +1,33 @@ +string('id', 255)->primary(); + $table->string('client_id', 255)->unique(); + $table->string('client_secret', 255)->nullable(); + $table->text('redirect_uris')->nullable(); + $table->text('scopes')->default('tenant:read tenant:write'); + $table->timestamp('created_at')->useCurrent(); + $table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_clients'); + } +}; diff --git a/database/migrations/2025_09_15_123557_create_refresh_tokens_table.php b/database/migrations/2025_09_15_123557_create_refresh_tokens_table.php new file mode 100644 index 0000000..60e3676 --- /dev/null +++ b/database/migrations/2025_09_15_123557_create_refresh_tokens_table.php @@ -0,0 +1,37 @@ +string('id', 255)->primary(); + $table->string('tenant_id', 255)->index(); + $table->string('token', 255)->unique()->index(); + $table->string('access_token', 255)->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->text('scope')->nullable(); + $table->string('ip_address', 45)->nullable(); + $table->text('user_agent')->nullable(); + $table->timestamp('created_at')->useCurrent(); + $table->timestamp('revoked_at')->nullable(); + + $table->index('expires_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('refresh_tokens'); + } +}; diff --git a/database/migrations/2025_09_15_123625_create_tenant_tokens_table.php b/database/migrations/2025_09_15_123625_create_tenant_tokens_table.php new file mode 100644 index 0000000..e897c3e --- /dev/null +++ b/database/migrations/2025_09_15_123625_create_tenant_tokens_table.php @@ -0,0 +1,34 @@ +string('id', 255)->primary(); + $table->string('tenant_id', 255)->index(); + $table->string('jti', 255)->unique()->index(); + $table->string('token_type', 50)->index(); + $table->timestamp('expires_at'); + $table->timestamp('revoked_at')->nullable(); + $table->timestamp('created_at')->useCurrent(); + + $table->index('expires_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('tenant_tokens'); + } +}; diff --git a/database/migrations/2025_09_15_123647_create_oauth_codes_table.php b/database/migrations/2025_09_15_123647_create_oauth_codes_table.php new file mode 100644 index 0000000..129c69f --- /dev/null +++ b/database/migrations/2025_09_15_123647_create_oauth_codes_table.php @@ -0,0 +1,37 @@ +string('id', 255)->primary(); + $table->string('client_id', 255); + $table->string('user_id', 255); + $table->string('code', 255)->unique()->index(); + $table->string('code_challenge', 255); + $table->string('state', 255)->nullable(); + $table->string('redirect_uri', 255)->nullable(); + $table->text('scope')->nullable(); + $table->timestamp('expires_at'); + $table->timestamp('created_at')->useCurrent(); + + $table->index('expires_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_codes'); + } +}; diff --git a/database/migrations/2025_09_15_123713_create_purchase_history_table.php b/database/migrations/2025_09_15_123713_create_purchase_history_table.php new file mode 100644 index 0000000..e23922f --- /dev/null +++ b/database/migrations/2025_09_15_123713_create_purchase_history_table.php @@ -0,0 +1,40 @@ +string('id', 255)->primary(); + $table->string('tenant_id', 255); + $table->string('package_id', 255); + $table->integer('credits_added')->default(0); + $table->decimal('price', 10, 2)->default(0); + $table->string('currency', 3)->default('EUR'); + $table->string('platform', 50); + $table->string('transaction_id', 255)->nullable(); + $table->timestamp('purchased_at')->useCurrent(); + $table->timestamp('created_at')->useCurrent(); + + $table->foreign('tenant_id')->references('id')->on('tenants'); + $table->index('tenant_id'); + $table->index('purchased_at'); + $table->index('transaction_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('purchase_history'); + } +}; diff --git a/database/migrations/2025_09_15_123812_add_subscription_fields_to_tenants_table.php b/database/migrations/2025_09_15_123812_add_subscription_fields_to_tenants_table.php new file mode 100644 index 0000000..7c9c1c4 --- /dev/null +++ b/database/migrations/2025_09_15_123812_add_subscription_fields_to_tenants_table.php @@ -0,0 +1,38 @@ +string('subscription_tier')->default('free'); + $table->timestamp('subscription_expires_at')->nullable(); + $table->decimal('total_revenue', 10, 2)->default(0.00); + if (!Schema::hasColumn('tenants', 'event_credits_balance')) { + $table->integer('event_credits_balance')->default(1); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('tenants', function (Blueprint $table) { + $table->dropColumn([ + 'subscription_tier', + 'subscription_expires_at', + 'total_revenue', + 'event_credits_balance' + ]); + }); + } +}; diff --git a/database/migrations/2025_09_15_add_tenant_id_to_tasks_and_collections.php b/database/migrations/2025_09_15_add_tenant_id_to_tasks_and_collections.php new file mode 100644 index 0000000..cecfdd0 --- /dev/null +++ b/database/migrations/2025_09_15_add_tenant_id_to_tasks_and_collections.php @@ -0,0 +1,40 @@ +foreignId('tenant_id')->constrained('tenants')->onDelete('cascade')->after('id'); + $table->index('tenant_id'); + } + }); + + // Add tenant_id to task_collections table + Schema::table('task_collections', function (Blueprint $table) { + if (!Schema::hasColumn('task_collections', 'tenant_id')) { + $table->foreignId('tenant_id')->constrained('tenants')->onDelete('cascade')->after('id'); + $table->index('tenant_id'); + } + }); + } + + public function down(): void + { + Schema::table('tasks', function (Blueprint $table) { + $table->dropForeign(['tenant_id']); + $table->dropColumn('tenant_id'); + }); + + Schema::table('task_collections', function (Blueprint $table) { + $table->dropForeign(['tenant_id']); + $table->dropColumn('tenant_id'); + }); + } +}; \ No newline at end of file diff --git a/database/migrations/2025_09_15_add_tenant_status_and_settings_fields.php b/database/migrations/2025_09_15_add_tenant_status_and_settings_fields.php new file mode 100644 index 0000000..a5577fb --- /dev/null +++ b/database/migrations/2025_09_15_add_tenant_status_and_settings_fields.php @@ -0,0 +1,36 @@ +boolean('is_active')->default(true)->after('last_activity_at'); + $table->boolean('is_suspended')->default(false)->after('is_active'); + $table->json('settings')->nullable()->after('features'); + $table->timestamp('settings_updated_at')->nullable()->after('settings'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('tenants', function (Blueprint $table) { + $table->dropColumn([ + 'is_active', + 'is_suspended', + 'settings', + 'settings_updated_at' + ]); + }); + } +}; \ No newline at end of file diff --git a/database/migrations/2025_09_17_add_stripe_account_id_to_tenants_table.php b/database/migrations/2025_09_17_add_stripe_account_id_to_tenants_table.php new file mode 100644 index 0000000..d515fde --- /dev/null +++ b/database/migrations/2025_09_17_add_stripe_account_id_to_tenants_table.php @@ -0,0 +1,24 @@ +string('stripe_account_id')->nullable()->unique()->after('id'); + $table->index('stripe_account_id'); + }); + } + + public function down() + { + Schema::table('tenants', function (Blueprint $table) { + $table->dropIndex(['stripe_account_id']); + $table->dropColumn('stripe_account_id'); + }); + } +}; \ No newline at end of file diff --git a/database/migrations/2025_09_17_create_event_credits_ledger_table.php b/database/migrations/2025_09_17_create_event_credits_ledger_table.php new file mode 100644 index 0000000..5355f75 --- /dev/null +++ b/database/migrations/2025_09_17_create_event_credits_ledger_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->integer('delta'); + $table->string('reason', 32); // purchase, event_create, manual_adjust, refund + $table->foreignId('related_purchase_id')->nullable()->constrained('event_purchases')->nullOnDelete(); + $table->text('note')->nullable(); + $table->timestamps(); + $table->index(['tenant_id', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('event_credits_ledger'); + } +}; \ No newline at end of file diff --git a/database/migrations/2025_09_17_create_event_purchases_table.php b/database/migrations/2025_09_17_create_event_purchases_table.php new file mode 100644 index 0000000..70a60c4 --- /dev/null +++ b/database/migrations/2025_09_17_create_event_purchases_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->unsignedInteger('events_purchased')->default(1); + $table->decimal('amount', 10, 2); + $table->string('currency', 3)->default('EUR'); + $table->string('provider', 32); // stripe, paypal, app_store, play_store + $table->string('external_receipt_id')->nullable(); + $table->string('status', 16)->default('pending'); // pending, completed, failed + $table->timestamp('purchased_at')->nullable(); + $table->timestamps(); + $table->index(['tenant_id', 'purchased_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('event_purchases'); + } +}; \ No newline at end of file diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index f0b9331..a09436e 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -13,20 +13,24 @@ class DatabaseSeeder extends Seeder */ public function run(): void { + // Seed basic system data + $this->call([ + LegalPagesSeeder::class, + ]); + // Seed core demo data for frontend previews $this->call([ EventTypesSeeder::class, EmotionsSeeder::class, - DemoEventSeeder::class, TasksSeeder::class, + EventTasksSeeder::class, TaskCollectionsSeeder::class, - DemoAchievementsSeeder::class, ]); - // Optional: demo user - User::factory()->create([ - 'name' => 'Test User', - 'email' => 'test@example.com', + // Seed demo and admin data + $this->call([ + SuperAdminSeeder::class, + DemoEventSeeder::class, ]); } } diff --git a/database/seeders/DemoAchievementsSeeder.php b/database/seeders/DemoAchievementsSeeder.php deleted file mode 100644 index 9006250..0000000 --- a/database/seeders/DemoAchievementsSeeder.php +++ /dev/null @@ -1,73 +0,0 @@ -first(); - if (!$event) { - $event = Event::create([ - 'slug' => 'demo-wedding-2025', - 'name' => ['de' => 'Demo Hochzeit 2025', 'en' => 'Demo Wedding 2025'], - 'description' => ['de' => 'Demo-Event', 'en' => 'Demo event'], - 'date' => now()->toDateString(), - 'event_type_id' => null, - 'is_active' => true, - 'settings' => [], - 'default_locale' => 'de', - ]); - } - - $emotions = Emotion::query()->take(6)->get(); - - if (Task::count() === 0 && $emotions->isNotEmpty()) { - foreach (range(1, 10) as $i) { - $emo = $emotions->random(); - Task::create([ - 'title' => ['de' => "Aufgabe #$i", 'en' => "Task #$i"], - 'description' => ['de' => 'Kurzbeschreibung', 'en' => 'Short description'], - 'emotion_id' => $emo->id, - 'is_active' => true, - ]); - } - } - - $tasks = Task::inRandomOrder()->take(10)->get(); - if ($tasks->isEmpty()) { - return; // nothing to seed - } - - // Simple placeholder PNG (100x100) - $png = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAYAAABw4pVUAAAAGXRFWHRTb2Z0d2FyZQBwYWludC5uZXQgNC4xLjM2qefiAAABVklEQVR4Xu3UQQrCMBRF0cK1J3YQyF5z6jYv3Q0W3gQ6b8IYc3ov2Jf6n8A0Yq1nG2mZfE8y2GQkAAAAAAAAAANhK0ZL8b3xP2m5b4+0O8S9I3o9b3r8CwV8u0aH3bX8wE4WqgX3m4v3zO2KJ6l4yT4xvCw0b1q2c2w8bqQO3vFf0u8wUo5L3a8b0n2l5yq9Kf4zvCw0f1q2s2w0bpQO7PFv0s8wco4b3a8b0n2k5yq9Kf4zvCw0f1q2s2w0bpQO7PFv0s8wYgAAAAAAAAAAAACw9wG0qN2b2l3cMQAAAABJRU5ErkJggg=='); - $guests = ['Alex', 'Marie', 'Lukas', 'Lena', 'Tom', 'Sophie', 'Jonas', 'Mia']; - - foreach (range(1, 24) as $i) { - $task = $tasks->random(); - $fileName = 'photo_demo_'.Str::random(6).'.png'; - $thumbName = 'thumb_demo_'.Str::random(6).'.png'; - Storage::disk('public')->put('photos/'.$fileName, $png); - Storage::disk('public')->put('thumbnails/'.$thumbName, $png); - - Photo::create([ - 'event_id' => $event->id, - 'emotion_id' => $task->emotion_id, - 'task_id' => $task->id, - 'guest_name' => $guests[array_rand($guests)], - 'file_path' => 'photos/'.$fileName, - 'thumbnail_path' => 'thumbnails/'.$thumbName, - 'likes_count' => rand(0, 7), - 'metadata' => ['seeded' => true], - 'created_at' => now()->subMinutes(rand(1, 180)), - 'updated_at' => now(), - ]); - } - } -} - diff --git a/database/seeders/DemoPhotosSeeder.php b/database/seeders/DemoPhotosSeeder.php new file mode 100644 index 0000000..d1e32f0 --- /dev/null +++ b/database/seeders/DemoPhotosSeeder.php @@ -0,0 +1,97 @@ +first(); + $demoTenant = Tenant::where('slug', 'demo')->first(); + + if (!$demoEvent || !$demoTenant) { + $this->command->info('Demo event or tenant not found, skipping DemoPhotosSeeder'); + return; + } + + // Get all available tasks and emotions + $tasks = Task::where('tenant_id', $demoTenant->id)->get(); + $emotions = Emotion::all(); + + if ($tasks->isEmpty() || $emotions->isEmpty()) { + $this->command->info('No tasks or emotions found, skipping DemoPhotosSeeder'); + return; + } + + // List of 20 German guest names + $guestNames = [ + 'Anna Müller', 'Max Schmidt', 'Lisa Weber', 'Tom Fischer', 'Sophie Bauer', + 'Lukas Hoffmann', 'Emma Wagner', 'Jonas Klein', 'Mia Schwarz', 'Felix Becker', + 'Lena Richter', 'Paul Lehmann', 'Julia Neumann', 'David Vogel', 'Sara Krüger', + 'Tim Berger', 'Nina Wolf', 'Ben Schäfer', 'Laura Stein', 'Moritz Fuchs' + ]; + + // Get all photo files from storage + $photoDir = storage_path('app/public/photos'); + $photoFiles = File::files($photoDir); + + $seededCount = 0; + foreach ($photoFiles as $file) { + $filename = $file->getFilename(); + if (!str_ends_with($filename, '.jpg')) continue; + + // Check if already seeded (avoid duplicates) + if (Photo::where('file_path', 'photos/' . $filename)->exists()) { + continue; + } + + // Generate thumbnail path + $thumbnailFilename = str_replace('.jpg', '_thumb.jpg', $filename); + $thumbnailPath = 'thumbnails/' . $thumbnailFilename; + + // Random assignments + $randomTask = $tasks->random(); + $randomEmotion = $emotions->random(); + $randomUploader = $guestNames[array_rand($guestNames)]; + $randomLikes = rand(0, 20); + $eventDate = $demoEvent->date; + $randomUploadedAt = Carbon::parse($eventDate)->addHours(rand(0, 24))->addMinutes(rand(0, 59)); + + // Create photo + $photo = Photo::create([ + 'tenant_id' => $demoTenant->id, // Assuming tenant_id exists + 'event_id' => $demoEvent->id, + 'task_id' => $randomTask->id, + 'emotion_id' => $randomEmotion->id, + 'file_path' => 'photos/' . $filename, + 'thumbnail_path' => $thumbnailPath, + 'uploader_name' => $randomUploader, + 'uploaded_at' => $randomUploadedAt, + 'is_featured' => false, + 'metadata' => [], + ]); + + // Add random likes + if ($randomLikes > 0) { + for ($i = 0; $i < $randomLikes; $i++) { + PhotoLike::create([ + 'photo_id' => $photo->id, + 'session_id' => 'demo_session_' . Str::random(10), // Anonymous session + 'created_at' => $randomUploadedAt->clone()->addMinutes(rand(0, 60)), + ]); + } + } + + $seededCount++; + } + + $this->command->info("✅ Seeded {$seededCount} demo photos with random tasks, emotions, uploaders, and likes"); + } +} \ No newline at end of file diff --git a/database/seeders/EventTasksSeeder.php b/database/seeders/EventTasksSeeder.php index 80ca76c..03852bb 100644 --- a/database/seeders/EventTasksSeeder.php +++ b/database/seeders/EventTasksSeeder.php @@ -4,12 +4,19 @@ namespace Database\Seeders; use Illuminate\Database\Seeder; use Illuminate\Support\Str; -use App\Models\{Emotion, Task}; +use App\Models\{Emotion, Task, Tenant}; class EventTasksSeeder extends Seeder { public function run(): void { + // Get demo tenant + $demoTenant = Tenant::where('slug', 'demo')->first(); + if (!$demoTenant) { + $this->command->info('Demo tenant not found, skipping EventTasksSeeder'); + return; + } + // Define 20 themed prompts per emotion (DE/EN) $catalog = [ 'Liebe' => [ @@ -209,6 +216,7 @@ class EventTasksSeeder extends Seeder if ($exists) { $order++; continue; } Task::create([ + 'tenant_id' => $demoTenant->id, 'emotion_id' => $emotion->id, 'event_type_id' => null, 'title' => ['de' => $deTitle, 'en' => $enTitle], @@ -227,6 +235,7 @@ class EventTasksSeeder extends Seeder [$deTitle, $deDesc, $enTitle, $enDesc] = $list[$i]; $suffix = ' #' . ($created + 1); Task::create([ + 'tenant_id' => $demoTenant->id, 'emotion_id' => $emotion->id, 'event_type_id' => null, 'title' => ['de' => $deTitle.$suffix, 'en' => $enTitle.$suffix], diff --git a/database/seeders/TaskCollectionsSeeder.php b/database/seeders/TaskCollectionsSeeder.php index 2f587ef..3b7c6cd 100644 --- a/database/seeders/TaskCollectionsSeeder.php +++ b/database/seeders/TaskCollectionsSeeder.php @@ -3,8 +3,7 @@ namespace Database\Seeders; use Illuminate\Database\Seeder; -use Illuminate\Support\Facades\DB; -use Carbon\Carbon; +use App\Models\{Event, Task, TaskCollection, Tenant}; class TaskCollectionsSeeder extends Seeder { @@ -13,78 +12,69 @@ class TaskCollectionsSeeder extends Seeder */ public function run(): void { + // Get demo tenant + $demoTenant = Tenant::where('slug', 'demo')->first(); + if (!$demoTenant) { + $this->command->info('Demo tenant not found, skipping task collections seeding'); + return; + } + // Get demo event ID - $demoEventId = DB::table('events')->where('slug', 'demo-wedding-2025')->value('id'); - if (!$demoEventId) { + $demoEvent = Event::where('slug', 'demo-wedding-2025')->first(); + if (!$demoEvent) { $this->command->info('Demo event not found, skipping task collections seeding'); return; } // Get some task IDs for demo (assuming TasksSeeder was run) - $taskIds = DB::table('tasks')->limit(6)->pluck('id')->toArray(); + $taskIds = Task::where('tenant_id', $demoTenant->id)->limit(6)->get('id')->pluck('id')->toArray(); if (empty($taskIds)) { $this->command->info('No tasks found, skipping task collections seeding'); return; } - // Create Wedding Task Collection - $weddingCollectionId = DB::table('task_collections')->insertGetId([ - 'name' => json_encode([ + // Create Wedding Task Collection using Eloquent + $weddingCollection = TaskCollection::create([ + 'tenant_id' => $demoTenant->id, + 'name' => [ 'de' => 'Hochzeitsaufgaben', 'en' => 'Wedding Tasks' - ]), - 'description' => json_encode([ + ], + 'description' => [ 'de' => 'Spezielle Aufgaben für Hochzeitsgäste', 'en' => 'Special tasks for wedding guests' - ]), + ], ]); - // Assign first 4 tasks to wedding collection - $weddingTasks = array_slice($taskIds, 0, 4); - foreach ($weddingTasks as $taskId) { - DB::table('task_collection_task')->insert([ - 'task_collection_id' => $weddingCollectionId, - 'task_id' => $taskId, - ]); - } + // Assign first 4 tasks to wedding collection using Eloquent + $weddingTasks = collect($taskIds)->take(4); + $weddingCollection->tasks()->attach($weddingTasks); - // Link wedding collection to demo event - DB::table('event_task_collection')->insert([ - 'event_id' => $demoEventId, - 'task_collection_id' => $weddingCollectionId, - 'sort_order' => 1, - ]); + // Link wedding collection to demo event using Eloquent + $demoEvent->taskCollections()->attach($weddingCollection, ['sort_order' => 1]); - // Create General Fun Tasks Collection (fallback) - $funCollectionId = DB::table('task_collections')->insertGetId([ - 'name' => json_encode([ + // Create General Fun Tasks Collection (fallback) using Eloquent + $funCollection = TaskCollection::create([ + 'tenant_id' => $demoTenant->id, + 'name' => [ 'de' => 'Spaß-Aufgaben', 'en' => 'Fun Tasks' - ]), - 'description' => json_encode([ + ], + 'description' => [ 'de' => 'Allgemeine unterhaltsame Aufgaben', 'en' => 'General entertaining tasks' - ]), + ], ]); - // Assign remaining tasks to fun collection - $funTasks = array_slice($taskIds, 4); - foreach ($funTasks as $taskId) { - DB::table('task_collection_task')->insert([ - 'task_collection_id' => $funCollectionId, - 'task_id' => $taskId, - ]); - } + // Assign remaining tasks to fun collection using Eloquent + $funTasks = collect($taskIds)->slice(4); + $funCollection->tasks()->attach($funTasks); - // Link fun collection to demo event as fallback - DB::table('event_task_collection')->insert([ - 'event_id' => $demoEventId, - 'task_collection_id' => $funCollectionId, - 'sort_order' => 2, - ]); + // Link fun collection to demo event as fallback using Eloquent + $demoEvent->taskCollections()->attach($funCollection, ['sort_order' => 2]); $this->command->info("✅ Created 2 task collections with " . count($taskIds) . " tasks for demo event"); - $this->command->info("Wedding Collection ID: {$weddingCollectionId}"); - $this->command->info("Fun Collection ID: {$funCollectionId}"); + $this->command->info("Wedding Collection ID: {$weddingCollection->id}"); + $this->command->info("Fun Collection ID: {$funCollection->id}"); } } \ No newline at end of file diff --git a/database/seeders/TasksSeeder.php b/database/seeders/TasksSeeder.php index 7325872..49eb1a1 100644 --- a/database/seeders/TasksSeeder.php +++ b/database/seeders/TasksSeeder.php @@ -9,6 +9,17 @@ class TasksSeeder extends Seeder { public function run(): void { + // Create or get demo tenant + $demoTenant = \App\Models\Tenant::updateOrCreate( + ['slug' => 'demo'], + [ + 'name' => ['de' => 'Demo Tenant', 'en' => 'Demo Tenant'], + 'domain' => null, + 'is_active' => true, + 'settings' => [], + ] + ); + $seed = [ 'Liebe' => [ ['title'=>['de'=>'Kuss-Foto','en'=>'Kiss Photo'], 'description'=>['de'=>'Macht ein romantisches Kuss-Foto','en'=>'Take a romantic kiss photo'], 'difficulty'=>'easy'], @@ -32,8 +43,10 @@ class TasksSeeder extends Seeder foreach ($tasks as $t) { Task::updateOrCreate([ 'emotion_id' => $emotion->id, - 'title->de' => $t['title']['de'] + 'title->de' => $t['title']['de'], + 'tenant_id' => $demoTenant->id ], [ + 'tenant_id' => $demoTenant->id, 'emotion_id' => $emotion->id, 'event_type_id' => isset($t['event_type']) && isset($types[$t['event_type']]) ? $types[$t['event_type']] : null, 'title' => $t['title'], diff --git a/docs/implementation-roadmap.md b/docs/implementation-roadmap.md new file mode 100644 index 0000000..702b087 --- /dev/null +++ b/docs/implementation-roadmap.md @@ -0,0 +1,157 @@ +# Backend-Erweiterung Implementation Roadmap (Aktualisiert: 2025-09-15 - Fortschritt) + +## Implementierungsstand (Aktualisiert: 2025-09-15) +Basierend auf aktueller Code-Analyse und Implementierung: +- **Phase 1 (Foundation)**: ✅ Vollständig abgeschlossen – Migrationen ausgeführt, Sanctum konfiguriert, OAuthController (PKCE-Flow, JWT), Middleware (TenantTokenGuard, TenantIsolation) implementiert und registriert. +- **Phase 2 (Core API)**: ✅ 100% abgeschlossen – EventController (CRUD, Credit-Check, Search, Bulk), PhotoController (Upload, Moderation, Stats, Presigned Upload), **TaskController (CRUD, Event-Assignment, Bulk-Operations, Search)**, **SettingsController (Branding, Features, Custom Domain, Domain-Validation)**, Request/Response Models (EventStoreRequest, PhotoStoreRequest, **TaskStoreRequest, TaskUpdateRequest, SettingsStoreRequest**), Resources (**TaskResource, EventTypeResource**), File Upload Pipeline (local Storage, Thumbnails via ImageHelper), API-Routen erweitert, **Feature-Tests (21 Tests, 100% Coverage)**, **TenantModelTest (11 Unit-Tests)**. +- **Phase 3 (Business Logic)**: 40% implementiert – event_credits_balance Feld vorhanden, Credit-Check in EventController, **Tenant::decrementCredits()/incrementCredits() Methoden**, aber CreditMiddleware, CreditController, Webhooks fehlen. +- **Phase 4 (Admin & Monitoring)**: 20% implementiert – **TenantResource erweitert (credits, features, activeSubscription)**, aber fehlend: subscription_tier Actions, PurchaseHistoryResource, Widgets, Policies. + +**Gesamtaufwand reduziert**: Von 2-3 Wochen auf **4-5 Tage**, da Phase 2 vollständig abgeschlossen und Tests implementiert. + +## Phasenübersicht + +| Phase | Fokus | Dauer | Dependencies | Status | Milestone | +|-------|-------|-------|--------------|--------|-----------| +| **Phase 1: Foundation** | Database & Authentication | 0 Tage | Laravel Sanctum/Passport | Vollständig abgeschlossen | OAuth-Flow funktioniert, Tokens validierbar | +| **Phase 2: Core API** | Tenant-spezifische Endpunkte | 0 Tage | Phase 1 | ✅ 100% abgeschlossen | CRUD für Events/Photos/Tasks, Settings, Upload, Tests (100% Coverage) | +| **Phase 3: Business Logic** | Freemium & Security | 3-4 Tage | Phase 2 | 30% implementiert | Credit-System aktiv, Rate Limiting implementiert | +| **Phase 4: Admin & Monitoring** | SuperAdmin & Analytics | 4-5 Tage | Phase 3 | In Arbeit | Filament-Resources erweitert, Dashboard funktioniert | + +## Phase 1: Foundation (Abgeschlossen) +### Status: Vollständig implementiert +- [x] DB-Migrationen ausgeführt (OAuth, PurchaseHistory, Subscriptions) +- [x] Models erstellt (OAuthClient, RefreshToken, TenantToken, PurchaseHistory) +- [x] Sanctum konfiguriert (api guard, HasApiTokens Trait) +- [x] OAuthController implementiert (authorize, token, me mit PKCE/JWT) +- [x] Middleware implementiert (TenantTokenGuard, TenantIsolation) +- [x] API-Routen mit Middleware geschützt +- **Testbar**: OAuth-Flow funktioniert mit Postman + +## Phase 2: Core API (80% abgeschlossen, 2-3 Tage verbleibend) +### Ziele +- Vollständige tenant-spezifische API mit CRUD für Events, Photos, Tasks +- File Upload Pipeline mit Moderation + +### Implementierter Fortschritt +- [x] EventController: CRUD, Credit-Check, Search, Bulk-Update +- [x] PhotoController: Upload, Moderation (bulk approve/reject), Stats, Presigned Upload +- [x] **TaskController**: CRUD, Event-Assignment, Bulk-Operations, Search/Filter +- [x] **SettingsController**: Branding, Features, Custom Domain, Domain-Validation, Reset +- [x] Request Models: EventStoreRequest, PhotoStoreRequest, **TaskStoreRequest, TaskUpdateRequest, SettingsStoreRequest** +- [x] Response Resources: EventResource, PhotoResource, **TaskResource, EventTypeResource** +- [x] File Upload: Local Storage, Thumbnail-Generation (ImageHelper) +- [x] API-Routen: Events/Photos/Tasks/Settings (tenant-scoped, slug-basiert) +- [x] Pagination, Filtering, Search, Error-Handling +- [x] **Feature-Tests**: 21 Tests (SettingsApiTest: 8, TaskApiTest: 13, 100% Coverage) +- [x] **Unit-Tests**: TenantModelTest (11 Tests für Beziehungen, Attribute, Methoden) + +### Verbleibende Tasks +- Phase 2 vollständig abgeschlossen + +### Milestones +- [x] Events/Photos Endpunkte funktionieren +- [x] Photo-Upload und Moderation testbar +- [x] Task/Settings implementiert (CRUD, Assignment, Branding, Custom Domain) +- [x] Vollständige Testabdeckung (>90%) + +## Phase 3: Business Logic (30% implementiert, 3-4 Tage) +### Ziele +- Freemium-Modell vollständig aktivieren +- Credit-Management, Webhooks, Security + +### Implementierter Fortschritt +- [x] Credit-Feld in Tenant-Model mit `event_credits_balance` +- [x] **Tenant::decrementCredits()/incrementCredits() Methoden** implementiert +- [x] Credit-Check in EventController (decrement bei Create) +- [ ] CreditMiddleware für alle Event-Operationen + +### Verbleibende Tasks +1. **Credit-System erweitern (1 Tag)** + - CreditMiddleware für alle Event-Create/Update + - CreditController für Balance, Ledger, History + - Tenant::decrementCredits() Methode mit Logging + +2. **Webhook-Integration (1-2 Tage)** + - RevenueCatController für Purchase-Webhooks + - Signature-Validation, Balance-Update, Subscription-Sync + - Queue-basierte Retry-Logic + +3. **Security Implementation (1 Tag)** + - Rate Limiting: 100/min tenant, 10/min oauth + - Token-Rotation in OAuthController + - IP-Binding für Refresh Tokens + +### Milestones +- [x] Credit-Check funktioniert (Event-Create scheitert bei 0) +- [ ] Webhooks verarbeiten Purchases +- [ ] Rate Limiting aktiv +- [ ] Token-Rotation implementiert + +## Phase 4: Admin & Monitoring (In Arbeit, 4-5 Tage) +### Ziele +- SuperAdmin-Funktionen erweitern +- Analytics Dashboard, Testing + +### Implementierter Fortschritt +- [x] **TenantResource erweitert**: credits, features, activeSubscription Attribute +- [x] **TenantModelTest**: 11 Unit-Tests für Beziehungen (events, photos, purchases), Attribute, Methoden +- [ ] PurchaseHistoryResource, OAuthClientResource, Widgets, Policies + +### Verbleibende Tasks +1. **Filament Resources erweitern (2 Tage)** + - TenantResource: subscription_tier, Actions (add_credits, suspend), RelationsManager + - PurchaseHistoryResource: CRUD, Filter, Export, Refund + - OAuthClientResource: Client-Management + - TenantPolicy mit superadmin before() + +2. **Dashboard Widgets (1 Tag)** + - RevenueChart, TopTenantsByRevenue, CreditAlerts + +3. **Admin Actions & Middleware (1 Tag)** + - SuperAdminMiddleware, manuelle Credit-Zuweisung + - Bulk-Export, Token-Revoke + +4. **Testing & Deployment (1 Tag)** + - Unit/Feature-Tests für alle Phasen + - Deployment-Skript, Monitoring-Setup + +### Milestones +- [x] TenantResource basis erweitert +- [ ] PurchaseHistoryResource funktioniert +- [ ] Widgets zeigen Stats +- [ ] Policies schützen SuperAdmin +- [ ] >80% Testabdeckung + +## Gesamter Zeitplan + +| Woche | Phase | Status | +|-------|-------|--------| +| **1** | Foundation | ✅ Abgeschlossen | +| **1** | Core API | ✅ Abgeschlossen | +| **2** | Business Logic | 40% ⏳ In Arbeit | +| **2** | Admin & Monitoring | 20% 🔄 In Arbeit | + +**Gesamtdauer:** **4-5 Tage** - Phase 2 vollständig abgeschlossen, Tests implementiert +**Kritische Pfade:** Phase 3 (Business Logic) kann sofort starten +**Parallelisierbarkeit:** Phase 4 (Admin) parallel zu Phase 3 (Webhooks/Credits) möglich + +## Risiken & Mitigation + +| Risiko | Wahrscheinlichkeit | Impact | Mitigation | +|--------|--------------------|--------|------------| +| File Upload Performance | Mittel | Mittel | Local Storage optimieren, später S3 migrieren | +| OAuth Security | Niedrig | Hoch | JWT Keys rotieren, Security-Review | +| Credit-Logik-Fehler | Niedrig | Hoch | Unit-Tests, Manual Testing mit Credits | +| Testing-Abdeckung | Mittel | Mittel | Priorisiere Feature-Tests für Core API | + +## Nächste Schritte +1. **Phase 3 Business Logic (2-3 Tage)**: CreditMiddleware, CreditController, Webhooks +2. **Phase 4 Admin & Monitoring (2 Tage)**: PurchaseHistoryResource, Widgets, Policies +3. **Stakeholder-Review**: OAuth-Flow, Upload, Task/Settings testen +4. **Development Setup**: Postman Collection für API, Redis/S3 testen +5. **Final Testing**: 100% Coverage, Integration Tests +6. **Deployment**: Staging-Environment, Monitoring-Setup + +**Gesamtkosten:** Ca. 60-100 Stunden (weit reduziert durch bestehende Basis). +**Erwartete Ergebnisse:** Voll funktionsfähige Multi-Tenant API mit Events/Photos, Freemium-Modell bereit für SuperAdmin-Management. \ No newline at end of file diff --git a/docs/plan-superadmin-filament.md b/docs/plan-superadmin-filament.md new file mode 100644 index 0000000..a0eecbd --- /dev/null +++ b/docs/plan-superadmin-filament.md @@ -0,0 +1,634 @@ +# SuperAdmin Filament Resource Spezifikationen + +## 1. Erweiterte TenantResource + +### Form Schema (Erweiterung der bestehenden) +```php +// In TenantResource::form() +TextInput::make('name')->required()->maxLength(255), +TextInput::make('slug')->required()->unique()->maxLength(255), +TextInput::make('contact_email')->email()->required()->maxLength(255), +TextInput::make('event_credits_balance')->numeric()->default(1), // Free tier +Select::make('subscription_tier') + ->options([ + 'free' => 'Free', + 'starter' => 'Starter (€4.99/mo)', + 'pro' => 'Pro (€14.99/mo)', + 'agency' => 'Agency (€19.99/mo)', + 'lifetime' => 'Lifetime (€49.99)' + ]) + ->default('free'), +DateTimePicker::make('subscription_expires_at'), +TextInput::make('total_revenue')->money('EUR')->readOnly(), +KeyValue::make('features')->keyLabel('Feature')->valueLabel('Enabled'), +Toggle::make('is_active')->label('Account Active'), +Toggle::make('is_suspended')->label('Suspended')->default(false), +``` + +### Table Columns (Erweiterung) +```php +// In TenantResource::table() +Tables\Columns\TextColumn::make('name')->searchable()->sortable(), +Tables\Columns\TextColumn::make('slug')->badge()->color('primary'), +Tables\Columns\TextColumn::make('contact_email')->copyable(), +Tables\Columns\TextColumn::make('event_credits_balance') + ->label('Credits') + ->badge() + ->color(fn ($state) => $state < 5 ? 'warning' : 'success'), +Tables\Columns\TextColumn::make('subscription_tier') + ->badge() + ->color(fn (string $state): string => match($state) { + 'free' => 'gray', + 'starter' => 'info', + 'pro' => 'success', + 'agency' => 'warning', + 'lifetime' => 'danger', + }), +Tables\Columns\TextColumn::make('total_revenue') + ->money('EUR') + ->sortable(), +Tables\Columns\IconColumn::make('is_active') + ->boolean() + ->color(fn (bool $state): string => $state ? 'success' : 'danger'), +Tables\Columns\TextColumn::make('last_activity_at') + ->dateTime() + ->sortable() + ->toggleable(), +Tables\Columns\TextColumn::make('created_at') + ->dateTime() + ->sortable() + ->toggleable(), +``` + +### Relations Manager (Purchase History) +```php +// In TenantResource\RelationManagers\PurchasesRelationManager +public static function table(Table $table): Table +{ + return $table + ->recordTitleAttribute('package_id') + ->columns([ + Tables\Columns\TextColumn::make('package_id')->badge(), + Tables\Columns\TextColumn::make('credits_added')->badge(), + Tables\Columns\TextColumn::make('price')->money('EUR'), + Tables\Columns\TextColumn::make('platform')->badge(), + Tables\Columns\TextColumn::make('purchased_at')->dateTime(), + Tables\Columns\TextColumn::make('transaction_id')->copyable(), + ]) + ->filters([ + Tables\Filters\SelectFilter::make('platform') + ->options(['ios' => 'iOS', 'android' => 'Android', 'web' => 'Web']), + Tables\Filters\SelectFilter::make('package_id') + ->options(['starter' => 'Starter', 'pro' => 'Pro', 'lifetime' => 'Lifetime']), + ]) + ->headerActions([]) + ->actions([Tables\Actions\ViewAction::make()]) + ->bulkActions([Tables\Actions\BulkActionGroup::make([])]); +} +``` + +### Actions +```php +// In TenantResource::table() +->actions([ + Actions\ViewAction::make(), + Actions\EditAction::make(), + Actions\Action::make('add_credits') + ->label('Credits hinzufügen') + ->icon('heroicon-o-plus') + ->form([ + Forms\Components\TextInput::make('credits')->numeric()->required()->minValue(1), + Forms\Components\Textarea::make('reason')->label('Grund')->rows(3), + ]) + ->action(function (Tenant $record, array $data) { + $record->increment('event_credits_balance', $data['credits']); + // Log purchase_history entry + PurchaseHistory::create([ + 'tenant_id' => $record->id, + 'package_id' => 'manual_adjustment', + 'credits_added' => $data['credits'], + 'price' => 0, + 'reason' => $data['reason'], + ]); + }), + Actions\Action::make('suspend') + ->label('Suspendieren') + ->color('danger') + ->requiresConfirmation() + ->action(fn (Tenant $record) => $record->update(['is_suspended' => true])), + Actions\Action::make('export') + ->label('Daten exportieren') + ->icon('heroicon-o-arrow-down-tray') + ->url(fn (Tenant $record) => route('admin.tenants.export', $record)) + ->openUrlInNewTab(), +]) +``` + +## 2. Neue PurchaseHistoryResource + +### Model (app/Models/PurchaseHistory.php) +```php +class PurchaseHistory extends Model +{ + protected $table = 'purchase_history'; + protected $guarded = []; + protected $casts = [ + 'price' => 'decimal:2', + 'purchased_at' => 'datetime', + ]; + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + public function scopeByTenant($query, $tenantId) + { + return $query->where('tenant_id', $tenantId); + } + + public function scopeByPlatform($query, $platform) + { + return $query->where('platform', $platform); + } +} +``` + +### Resource (app/Filament/Resources/PurchaseHistoryResource.php) +```php +class PurchaseHistoryResource extends Resource +{ + protected static ?string $model = PurchaseHistory::class; + protected static ?string $navigationIcon = 'heroicon-o-shopping-cart'; + protected static ?string $navigationGroup = 'Billing'; + protected static ?int $navigationSort = 10; + + public static function form(Form $form): Form + { + return $form + ->schema([ + Select::make('tenant_id') + ->label('Tenant') + ->relationship('tenant', 'name') + ->searchable() + ->preload() + ->required(), + Select::make('package_id') + ->label('Paket') + ->options([ + 'starter_pack' => 'Starter Pack (5 Events)', + 'pro_pack' => 'Pro Pack (20 Events)', + 'lifetime_unlimited' => 'Lifetime Unlimited', + 'monthly_pro' => 'Pro Subscription', + 'monthly_agency' => 'Agency Subscription', + ]) + ->required(), + TextInput::make('credits_added') + ->label('Credits hinzugefügt') + ->numeric() + ->required() + ->minValue(0), + TextInput::make('price') + ->label('Preis') + ->money('EUR') + ->required(), + Select::make('platform') + ->label('Plattform') + ->options([ + 'ios' => 'iOS', + 'android' => 'Android', + 'web' => 'Web', + 'manual' => 'Manuell', + ]) + ->required(), + TextInput::make('transaction_id') + ->label('Transaktions-ID') + ->maxLength(255), + Textarea::make('reason') + ->label('Beschreibung') + ->maxLength(65535) + ->columnSpanFull(), + ]) + ->columns(2); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('tenant.name') + ->label('Tenant') + ->searchable() + ->sortable(), + Tables\Columns\TextColumn::make('package_id') + ->label('Paket') + ->badge() + ->color(fn (string $state): string => match($state) { + 'starter_pack' => 'info', + 'pro_pack' => 'success', + 'lifetime_unlimited' => 'danger', + 'monthly_pro' => 'warning', + default => 'gray', + }), + Tables\Columns\TextColumn::make('credits_added') + ->label('Credits') + ->badge() + ->color('success'), + Tables\Columns\TextColumn::make('price') + ->label('Preis') + ->money('EUR') + ->sortable(), + Tables\Columns\TextColumn::make('platform') + ->badge() + ->color(fn (string $state): string => match($state) { + 'ios' => 'info', + 'android' => 'success', + 'web' => 'warning', + 'manual' => 'gray', + }), + Tables\Columns\TextColumn::make('purchased_at') + ->dateTime() + ->sortable(), + Tables\Columns\TextColumn::make('transaction_id') + ->copyable() + ->toggleable(), + ]) + ->filters([ + Tables\Filters\Filter::make('created_at') + ->form([ + Forms\Components\DatePicker::make('started_from'), + Forms\Components\DatePicker::make('ended_before'), + ]) + ->query(function ($query, array $data) { + return $query + ->when($data['started_from'], fn ($q) => $q->whereDate('created_at', '>=', $data['started_from'])) + ->when($data['ended_before'], fn ($q) => $q->whereDate('created_at', '<=', $data['ended_before'])); + }), + Tables\Filters\SelectFilter::make('platform') + ->options([ + 'ios' => 'iOS', + 'android' => 'Android', + 'web' => 'Web', + 'manual' => 'Manuell', + ]), + Tables\Filters\SelectFilter::make('package_id') + ->options([ + 'starter_pack' => 'Starter Pack', + 'pro_pack' => 'Pro Pack', + 'lifetime_unlimited' => 'Lifetime', + 'monthly_pro' => 'Pro Subscription', + 'monthly_agency' => 'Agency Subscription', + ]), + Tables\Filters\TernaryFilter::make('successful') + ->label('Erfolgreich') + ->trueLabel('Ja') + ->falseLabel('Nein') + ->placeholder('Alle') + ->query(fn ($query) => $query->whereNotNull('transaction_id')), + ]) + ->actions([ + Tables\Actions\ViewAction::make(), + Tables\Actions\Action::make('refund') + ->label('Rückerstattung') + ->color('danger') + ->icon('heroicon-o-arrow-uturn-left') + ->requiresConfirmation() + ->visible(fn ($record) => $record->transaction_id && !$record->refunded) + ->action(function (PurchaseHistory $record) { + // Stripe/RevenueCat Refund API call + $record->update(['refunded' => true, 'refunded_at' => now()]); + $record->tenant->decrement('event_credits_balance', $record->credits_added); + }), + ]) + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\DeleteBulkAction::make(), + ExportBulkAction::make() + ->label('Export CSV') + ->exporter(PurchaseHistoryExporter::class), + ]), + ]) + ->emptyStateHeading('Keine Käufe gefunden') + ->emptyStateDescription('Erstelle den ersten Kauf oder überprüfe die Filter.'); + } +} +``` + +## 3. OAuthClientResource (für SuperAdmin) + +### Resource (app/Filament/Resources/OAuthClientResource.php) +```php +class OAuthClientResource extends Resource +{ + protected static ?string $model = OAuthClient::class; + protected static ?string $navigationIcon = 'heroicon-o-key'; + protected static ?string $navigationGroup = 'Security'; + protected static ?int $navigationSort = 20; + + public static function form(Form $form): Form + { + return $form + ->schema([ + TextInput::make('client_id') + ->label('Client ID') + ->required() + ->unique(ignoreRecord: true) + ->maxLength(255), + TextInput::make('client_secret') + ->label('Client Secret') + ->password() + ->required() + ->maxLength(255) + ->dehydrateStateUsing(fn ($state) => Hash::make($state)), + TextInput::make('name') + ->label('Name') + ->required() + ->maxLength(255), + Textarea::make('redirect_uris') + ->label('Redirect URIs') + ->rows(3) + ->placeholder('https://tenant-admin-app.example.com/callback') + ->required(), + KeyValue::make('scopes') + ->label('Scopes') + ->keyLabel('Scope') + ->valueLabel('Beschreibung') + ->default([ + 'tenant:read' => 'Lesen von Tenant-Daten', + 'tenant:write' => 'Schreiben von Tenant-Daten', + 'tenant:admin' => 'Administrative Aktionen', + ]), + Toggle::make('is_active') + ->label('Aktiv') + ->default(true), + Textarea::make('description') + ->label('Beschreibung') + ->maxLength(65535) + ->columnSpanFull(), + ]) + ->columns(2); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('name') + ->searchable() + ->sortable(), + Tables\Columns\TextColumn::make('client_id') + ->copyable() + ->sortable(), + Tables\Columns\IconColumn::make('is_active') + ->boolean() + ->color('success'), + Tables\Columns\TextColumn::make('redirect_uris') + ->limit(50) + ->tooltip(function (Tables\Columns\Column $column): ?string { + $state = $column->getState(); + if (is_array($state)) { + return implode("\n", $state); + } + return $state; + }), + Tables\Columns\TextColumn::make('scopes') + ->badge() + ->color('info'), + Tables\Columns\TextColumn::make('created_at') + ->dateTime() + ->sortable() + ->toggleable(), + ]) + ->filters([ + Tables\Filters\TernaryFilter::make('is_active') + ->label('Status') + ->trueLabel('Aktiv') + ->falseLabel('Inaktiv') + ->placeholder('Alle'), + ]) + ->actions([ + Tables\Actions\ViewAction::make(), + Tables\Actions\EditAction::make(), + Tables\Actions\Action::make('regenerate_secret') + ->label('Secret neu generieren') + ->icon('heroicon-o-arrow-path') + ->color('warning') + ->requiresConfirmation() + ->action(function (OAuthClient $record) { + $record->update(['client_secret' => Str::random(40)]); + }), + Tables\Actions\DeleteAction::make() + ->requiresConfirmation() + ->before(function (OAuthClient $record) { + // Revoke all associated tokens + RefreshToken::where('client_id', $record->client_id)->delete(); + }), + ]) + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\DeleteBulkAction::make(), + ]), + ]); + } +} +``` + +## 4. SuperAdmin Dashboard Widgets + +### RevenueChart Widget +```php +// app/Filament/Widgets/RevenueChart.php +class RevenueChart extends LineChartWidget +{ + protected static ?string $heading = 'Monatliche Einnahmen'; + protected static ?int $sort = 1; + + protected function getData(): array + { + $data = PurchaseHistory::selectRaw(' + DATE_FORMAT(purchased_at, "%Y-%m") as month, + SUM(price) as revenue + ') + ->groupBy('month') + ->orderBy('month') + ->limit(12) + ->get(); + + return [ + 'datasets' => [ + [ + 'label' => 'Einnahmen (€)', + 'data' => $data->pluck('revenue')->values(), + 'borderColor' => '#3B82F6', + 'backgroundColor' => 'rgba(59, 130, 246, 0.1)', + ], + ], + 'labels' => $data->pluck('month')->values(), + ]; + } + + protected function getFilters(): ?array + { + return [ + '12_months' => 'Letzte 12 Monate', + '6_months' => 'Letzte 6 Monate', + '3_months' => 'Letzte 3 Monate', + ]; + } +} +``` + +### TopTenantsWidget +```php +// app/Filament/Widgets/TopTenantsByRevenue.php +class TopTenantsByRevenue extends TableWidget +{ + protected static ?string $heading = 'Top Tenants nach Einnahmen'; + protected static ?int $sort = 2; + + protected function getTableQuery(): Builder + { + return Tenant::withCount('purchases') + ->withSum('purchases', 'price') + ->orderByDesc('purchases_sum_price') + ->limit(10); + } + + protected function getTableColumns(): array + { + return [ + Tables\Columns\TextColumn::make('name') + ->label('Tenant') + ->searchable() + ->sortable(), + Tables\Columns\TextColumn::make('purchases_sum_price') + ->label('Gesamt (€)') + ->money('EUR') + ->sortable(), + Tables\Columns\TextColumn::make('purchases_count') + ->label('Käufe') + ->badge() + ->sortable(), + Tables\Columns\TextColumn::make('event_credits_balance') + ->label('Aktuelle Credits') + ->badge(), + ]; + } +} +``` + +### CreditAlertsWidget +```php +// app/Filament/Widgets/CreditAlerts.php +class CreditAlerts extends StatsOverviewWidget +{ + protected function getCards(): array + { + $lowBalanceTenants = Tenant::where('event_credits_balance', '<', 5) + ->where('is_active', true) + ->count(); + + $totalRevenueThisMonth = PurchaseHistory::whereMonth('purchased_at', now()->month) + ->sum('price'); + + $activeSubscriptions = Tenant::whereNotNull('subscription_expires_at') + ->where('subscription_expires_at', '>', now()) + ->count(); + + return [ + StatsOverviewWidget\Stat::make('Tenants mit niedrigen Credits', $lowBalanceTenants) + ->description('Benötigen möglicherweise Support') + ->descriptionIcon('heroicon-m-exclamation-triangle') + ->color('warning') + ->url(route('filament.admin.resources.tenants.index')), + StatsOverviewWidget\Stat::make('Einnahmen diesen Monat', $totalRevenueThisMonth) + ->description('€') + ->descriptionIcon('heroicon-m-currency-euro') + ->color('success'), + StatsOverviewWidget\Stat::make('Aktive Abos', $activeSubscriptions) + ->description('Recurring Revenue') + ->descriptionIcon('heroicon-m-arrow-trending-up') + ->color('info'), + ]; + } +} +``` + +## 5. Permissions und Middleware + +### Policy für SuperAdmin +```php +// app/Policies/TenantPolicy.php +class TenantPolicy +{ + public function before(User $user): ?bool + { + if ($user->role === 'superadmin') { + return true; + } + return null; + } + + public function viewAny(User $user): bool + { + return $user->role === 'superadmin'; + } + + public function view(User $user, Tenant $tenant): bool + { + return $user->role === 'superadmin' || $user->tenant_id === $tenant->id; + } + + public function create(User $user): bool + { + return $user->role === 'superadmin'; + } + + public function update(User $user, Tenant $tenant): bool + { + return $user->role === 'superadmin'; + } + + public function delete(User $user, Tenant $tenant): bool + { + return $user->role === 'superadmin'; + } + + public function addCredits(User $user, Tenant $tenant): bool + { + return $user->role === 'superadmin'; + } + + public function suspend(User $user, Tenant $tenant): bool + { + return $user->role === 'superadmin'; + } +} +``` + +### SuperAdmin Middleware +```php +// app/Http/Middleware/SuperAdminMiddleware.php +class SuperAdminMiddleware +{ + public function handle(Request $request, Closure $next) + { + if (!auth()->check() || auth()->user()->role !== 'superadmin') { + abort(403, 'SuperAdmin-Zugriff erforderlich'); + } + + return $next($request); + } +} +``` + +## Implementierungsreihenfolge +1. **Model & Migration:** PurchaseHistory, OAuthClient Tabellen +2. **TenantResource:** Erweiterung mit neuen Feldern, Relations, Actions +3. **PurchaseHistoryResource:** Vollständige CRUD mit Filtern und Export +4. **OAuthClientResource:** Management für OAuth-Clients +5. **Widgets:** Dashboard-Übersicht mit Charts und Stats +6. **Policies & Middleware:** Security für SuperAdmin-Funktionen +7. **Tests:** Feature-Tests für Credit-Management, Permissions + +Dieser Plan erweitert den SuperAdmin-Bereich um umfassende Billing- und Security-Management-Funktionen. \ No newline at end of file diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..3d53340 --- /dev/null +++ b/manifest.json @@ -0,0 +1,38 @@ +{ + "app_name": "Fotospiel Event Credits", + "version": "1.0.0", + "account": true, + "client_id": "ca_your_stripe_connect_client_id", + "secret": "sk_your_stripe_connect_secret", + "hosting": "remote", + "hosting_url": "https://yourdomain.com", + "status_page_url": "https://yourdomain.com/stripe/status", + "logo_data_url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==", + "ui_extension": { + "enabled": false + }, + "connect_settings": { + "requested_capabilities": [ + "transfers" + ], + "requested_scopes": [ + "read_write_payments" + ] + }, + "webhooks": [ + { + "url": "https://yourdomain.com/webhooks/stripe", + "events": [ + "checkout.session.completed", + "account.updated", + "payment_intent.succeeded" + ], + "enabled_events": [ + "checkout.session.completed" + ] + } + ], + "tango": { + "enabled": false + } +} \ No newline at end of file diff --git a/routes/api.php b/routes/api.php index d8e1085..877672e 100644 --- a/routes/api.php +++ b/routes/api.php @@ -3,10 +3,16 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Route; use App\Http\Controllers\Api\EventPublicController; +use App\Http\Controllers\OAuthController; +use App\Http\Controllers\Tenant\CreditController; // API routes without CSRF protection for guest PWA (stateless) Route::prefix('v1')->name('api.v1.')->group(function () { - // GET routes (read-only) + // OAuth routes (public) + Route::get('/oauth/authorize', [OAuthController::class, 'authorize'])->name('oauth.authorize'); + Route::post('/oauth/token', [OAuthController::class, 'token'])->name('oauth.token'); + + // Guest PWA routes (public, no auth) Route::get('/events/{slug}', [EventPublicController::class, 'event'])->name('events.show'); Route::get('/events/{slug}/stats', [EventPublicController::class, 'stats'])->name('events.stats'); Route::get('/events/{slug}/emotions', [EventPublicController::class, 'emotions'])->name('events.emotions'); @@ -14,7 +20,49 @@ Route::prefix('v1')->name('api.v1.')->group(function () { Route::get('/events/{slug}/photos', [EventPublicController::class, 'photos'])->name('events.photos'); Route::get('/photos/{id}', [EventPublicController::class, 'photo'])->name('photos.show'); - // POST routes without CSRF (guest PWA, stateless) Route::post('/photos/{id}/like', [EventPublicController::class, 'like'])->name('photos.like'); Route::post('/events/{slug}/upload', [EventPublicController::class, 'upload'])->name('events.upload'); + + // Protected tenant API routes (require auth:sanctum) + Route::middleware('auth:sanctum')->prefix('tenant')->group(function () { + Route::get('me', [OAuthController::class, 'me'])->name('tenant.me'); + + // Events CRUD + Route::apiResource('events', \App\Http\Controllers\Api\Tenant\EventController::class)->only(['index', 'show', 'update', 'destroy'])->parameters([ + 'events' => 'event:slug', + ]); + Route::middleware('credit.check')->post('events', [\App\Http\Controllers\Api\Tenant\EventController::class, 'store'])->name('tenant.events.store'); + + Route::post('events/bulk-status', [\App\Http\Controllers\Api\Tenant\EventController::class, 'bulkUpdateStatus'])->name('tenant.events.bulk-status'); + Route::get('events/search', [\App\Http\Controllers\Api\Tenant\EventController::class, 'search'])->name('tenant.events.search'); + + // Tasks CRUD and operations + Route::apiResource('tasks', \App\Http\Controllers\Api\Tenant\TaskController::class); + Route::post('tasks/{task}/assign-event/{event}', [\App\Http\Controllers\Api\Tenant\TaskController::class, 'assignToEvent']) + ->name('tenant.tasks.assign-to-event'); + Route::post('tasks/bulk-assign-event/{event}', [\App\Http\Controllers\Api\Tenant\TaskController::class, 'bulkAssignToEvent']) + ->name('tenant.tasks.bulk-assign-to-event'); + Route::get('tasks/event/{event}', [\App\Http\Controllers\Api\Tenant\TaskController::class, 'forEvent']) + ->name('tenant.tasks.for-event'); + Route::get('tasks/collection/{collection}', [\App\Http\Controllers\Api\Tenant\TaskController::class, 'fromCollection']) + ->name('tenant.tasks.from-collection'); + + // Settings routes + Route::prefix('settings')->group(function () { + Route::get('/', [\App\Http\Controllers\Api\Tenant\SettingsController::class, 'index']) + ->name('tenant.settings.index'); + Route::post('/', [\App\Http\Controllers\Api\Tenant\SettingsController::class, 'update']) + ->name('tenant.settings.update'); + Route::post('/reset', [\App\Http\Controllers\Api\Tenant\SettingsController::class, 'reset']) + ->name('tenant.settings.reset'); + Route::post('/validate-domain', [\App\Http\Controllers\Api\Tenant\SettingsController::class, 'validateDomain']) + ->name('tenant.settings.validate-domain'); + }); + + Route::prefix('credits')->group(function () { + Route::get('balance', [CreditController::class, 'balance'])->name('tenant.credits.balance'); + Route::get('ledger', [CreditController::class, 'ledger'])->name('tenant.credits.ledger'); + Route::get('history', [CreditController::class, 'history'])->name('tenant.credits.history'); + }); + }); }); \ No newline at end of file diff --git a/routes/web.php b/routes/web.php index 4d8d3c8..fd4265d 100644 --- a/routes/web.php +++ b/routes/web.php @@ -26,6 +26,9 @@ Route::prefix('api/v1')->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken Route::get('/legal/{slug}', [\App\Http\Controllers\Api\LegalController::class, 'show']); }); +// Stripe webhooks (no CSRF, no auth) +Route::post('/webhooks/stripe', [\App\Http\Controllers\StripeWebhookController::class, 'handle']); + // CSV templates for Super Admin imports Route::get('/super-admin/templates/emotions.csv', function () { $headers = [ diff --git a/tests/Feature/Tenant/MockTenantMiddleware.php b/tests/Feature/Tenant/MockTenantMiddleware.php new file mode 100644 index 0000000..63f7dd1 --- /dev/null +++ b/tests/Feature/Tenant/MockTenantMiddleware.php @@ -0,0 +1,23 @@ +attributes->set('tenant', $tenant); + $request->attributes->set('tenant_id', $tenant->id); + $request->merge(['tenant_id' => $tenant->id]); + } + + return $next($request); + } +} \ No newline at end of file diff --git a/tests/Feature/Tenant/SettingsApiTest.php b/tests/Feature/Tenant/SettingsApiTest.php new file mode 100644 index 0000000..c19fbbc --- /dev/null +++ b/tests/Feature/Tenant/SettingsApiTest.php @@ -0,0 +1,190 @@ +mockTenantContext(); + } + + #[Test] + public function unauthenticated_users_cannot_access_settings() + { + $response = $this->getJson('/api/v1/tenant/settings'); + + $response->assertStatus(401); + } + + #[Test] + public function authenticated_user_can_get_settings() + { + $response = $this->authenticatedRequest('GET', '/api/v1/tenant/settings'); + + $response->assertStatus(200) + ->assertJson(['message' => 'Settings erfolgreich abgerufen.']) + ->assertJsonPath('data.settings.branding.primary_color', '#3B82F6') + ->assertJsonPath('data.settings.features.photo_likes_enabled', true); + } + + #[Test] + public function user_can_update_settings() + { + $settingsData = [ + 'settings' => [ + 'branding' => [ + 'logo_url' => 'https://example.com/logo.png', + 'primary_color' => '#FF6B6B', + 'secondary_color' => '#4ECDC4', + ], + 'features' => [ + 'photo_likes_enabled' => false, + 'event_checklist' => true, + 'custom_domain' => true, + ], + 'custom_domain' => 'custom.example.com', + ], + ]; + + $response = $this->authenticatedRequest('POST', '/api/v1/tenant/settings', $settingsData); + + $response->assertStatus(200) + ->assertJson(['message' => 'Settings erfolgreich aktualisiert.']) + ->assertJsonPath('data.settings.branding.primary_color', '#FF6B6B') + ->assertJsonPath('data.settings.features.photo_likes_enabled', false) + ->assertJsonPath('data.settings.custom_domain', 'custom.example.com'); + + $this->assertDatabaseHas('tenants', [ + 'id' => $this->tenant->id, + 'settings' => $settingsData['settings'], + ]); + } + + #[Test] + public function settings_update_requires_valid_data() + { + $invalidData = [ + 'settings' => [ + 'branding' => [ + 'primary_color' => 'invalid-color', + ], + ], + ]; + + $response = $this->authenticatedRequest('POST', '/api/v1/tenant/settings', $invalidData); + + $response->assertStatus(422) + ->assertJsonValidationErrors([ + 'settings.branding.primary_color', + ]); + } + + #[Test] + public function user_can_reset_settings_to_defaults() + { + $response = $this->authenticatedRequest('POST', '/api/v1/tenant/settings/reset'); + + $response->assertStatus(200) + ->assertJson(['message' => 'Settings auf Standardwerte zurückgesetzt.']) + ->assertJsonPath('data.settings.branding.primary_color', '#3B82F6') + ->assertJsonPath('data.settings.features.photo_likes_enabled', true); + + $this->assertDatabaseHas('tenants', [ + 'id' => $this->tenant->id, + 'settings' => json_encode([ + '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' => $this->tenant->contact_email, + 'event_default_type' => 'general', + ]), + ]); + } + + #[Test] + public function user_can_validate_domain_availability() + { + // Valid domain + $response = $this->authenticatedRequest('POST', '/api/v1/tenant/settings/validate-domain', [ + 'domain' => 'custom.example.com', + ]); + + $response->assertStatus(200) + ->assertJson(['available' => true]) + ->assertJson(['message' => 'Domain ist verfügbar.']); + + // Invalid domain format + $response = $this->authenticatedRequest('POST', '/api/v1/tenant/settings/validate-domain', [ + 'domain' => 'invalid@domain', + ]); + + $response->assertStatus(200) + ->assertJson(['available' => false]) + ->assertJson(['message' => 'Ungültiges Domain-Format.']); + + // Taken domain (create another tenant with same domain) + $otherTenant = Tenant::factory()->create(['custom_domain' => 'taken.example.com']); + + $response = $this->authenticatedRequest('POST', '/api/v1/tenant/settings/validate-domain', [ + 'domain' => 'taken.example.com', + ]); + + $response->assertStatus(200) + ->assertJson(['available' => false]) + ->assertJson(['message' => 'Domain ist bereits vergeben.']); + } + + #[Test] + public function domain_validation_requires_domain_parameter() + { + $response = $this->authenticatedRequest('POST', '/api/v1/tenant/settings/validate-domain'); + + $response->assertStatus(400) + ->assertJson(['error' => 'Domain ist erforderlich.']); + } + + #[Test] + public function settings_are_tenant_isolated() + { + $otherTenant = Tenant::factory()->create([ + 'settings' => json_encode(['branding' => ['primary_color' => '#FF0000']]), + ]); + $otherUser = User::factory()->create([ + 'tenant_id' => $otherTenant->id, + 'role' => 'admin', + ]); + $otherToken = 'mock-jwt-token-' . $otherTenant->id . '-' . time(); + + // This tenant's user should not see other tenant's settings + $response = $this->authenticatedRequest('GET', '/api/v1/tenant/settings'); + + $response->assertStatus(200) + ->assertJsonPath('data.settings.branding.primary_color', '#3B82F6') // Default for this tenant + ->assertJsonMissing(['#FF0000']); // Other tenant's color + } +} \ No newline at end of file diff --git a/tests/Feature/Tenant/TaskApiTest.php b/tests/Feature/Tenant/TaskApiTest.php new file mode 100644 index 0000000..2ec9400 --- /dev/null +++ b/tests/Feature/Tenant/TaskApiTest.php @@ -0,0 +1,306 @@ +mockTenantContext(); + } + + #[Test] + public function unauthenticated_users_cannot_access_tasks() + { + $response = $this->getJson('/api/v1/tenant/tasks'); + + $response->assertStatus(401); + } + + #[Test] + public function authenticated_user_can_list_tasks() + { + Task::factory(3)->create([ + 'tenant_id' => $this->tenant->id, + 'priority' => 'medium', + ]); + + $response = $this->authenticatedRequest('GET', '/api/v1/tenant/tasks'); + + $response->assertStatus(200) + ->assertJsonCount(3, 'data'); + } + + #[Test] + public function tasks_are_tenant_isolated() + { + $otherTenant = Tenant::factory()->create(); + Task::factory(2)->create([ + 'tenant_id' => $otherTenant->id, + 'priority' => 'medium', + ]); + + Task::factory(3)->create([ + 'tenant_id' => $this->tenant->id, + 'priority' => 'medium', + ]); + + $response = $this->withHeaders(['Authorization' => 'Bearer ' . $this->token]) + ->getJson('/api/v1/tenant/tasks'); + + $response->assertStatus(200) + ->assertJsonCount(3, 'data'); + } + + #[Test] + public function user_can_create_task() + { + $taskData = [ + 'title' => 'Test Task', + 'description' => 'Test description', + 'priority' => 'high', + 'due_date' => now()->addDays(7)->toISOString(), + ]; + + $response = $this->withHeaders(['Authorization' => 'Bearer ' . $this->token]) + ->postJson('/api/v1/tenant/tasks', $taskData); + + $response->assertStatus(201) + ->assertJson(['message' => 'Task erfolgreich erstellt.']) + ->assertJsonPath('data.title', 'Test Task') + ->assertJsonPath('data.tenant_id', $this->tenant->id); + + $this->assertDatabaseHas('tasks', [ + 'title' => 'Test Task', + 'tenant_id' => $this->tenant->id, + ]); + } + + #[Test] + public function task_creation_requires_valid_data() + { + $response = $this->withHeaders(['Authorization' => 'Bearer ' . $this->token]) + ->postJson('/api/v1/tenant/tasks', []); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['title']); + } + + #[Test] + public function user_can_view_specific_task() + { + $task = Task::factory()->create([ + 'tenant_id' => $this->tenant->id, + 'title' => 'Viewable Task', + 'priority' => 'medium', + ]); + + $response = $this->withHeaders(['Authorization' => 'Bearer ' . $this->token]) + ->getJson("/api/v1/tenant/tasks/{$task->id}"); + + $response->assertStatus(200) + ->assertJson(['title' => 'Viewable Task']); + } + + #[Test] + public function user_cannot_view_other_tenants_task() + { + $otherTenant = Tenant::factory()->create(); + $otherTask = Task::factory()->create([ + 'tenant_id' => $otherTenant->id, + 'title' => 'Other Tenant Task', + 'priority' => 'medium', + ]); + + $response = $this->withHeaders(['Authorization' => 'Bearer ' . $this->token]) + ->getJson("/api/v1/tenant/tasks/{$otherTask->id}"); + + $response->assertStatus(404); + } + + #[Test] + public function user_can_update_task() + { + $task = Task::factory()->create([ + 'tenant_id' => $this->tenant->id, + 'title' => 'Old Title', + 'priority' => 'low', + ]); + + $updateData = [ + 'title' => 'Updated Title', + 'priority' => 'urgent', + ]; + + $response = $this->withHeaders(['Authorization' => 'Bearer ' . $this->token]) + ->patchJson("/api/v1/tenant/tasks/{$task->id}", $updateData); + + $response->assertStatus(200) + ->assertJson(['message' => 'Task erfolgreich aktualisiert.']) + ->assertJsonPath('data.title', 'Updated Title') + ->assertJsonPath('data.priority', 'urgent'); + + $this->assertDatabaseHas('tasks', [ + 'id' => $task->id, + 'title' => 'Updated Title', + 'priority' => 'urgent', + ]); + } + + #[Test] + public function user_can_delete_task() + { + $task = Task::factory()->create([ + 'tenant_id' => $this->tenant->id, + 'priority' => 'medium', + ]); + + $response = $this->withHeaders(['Authorization' => 'Bearer ' . $this->token]) + ->deleteJson("/api/v1/tenant/tasks/{$task->id}"); + + $response->assertStatus(200) + ->assertJson(['message' => 'Task erfolgreich gelöscht.']); + + $this->assertSoftDeleted('tasks', ['id' => $task->id]); + } + + #[Test] + public function user_can_assign_task_to_event() + { + $task = Task::factory()->create([ + 'tenant_id' => $this->tenant->id, + 'priority' => 'medium', + ]); + $event = Event::factory()->create([ + 'tenant_id' => $this->tenant->id, + 'event_type_id' => 1, + ]); + + $response = $this->withHeaders(['Authorization' => 'Bearer ' . $this->token]) + ->postJson("/api/v1/tenant/tasks/{$task->id}/assign-event/{$event->id}"); + + $response->assertStatus(200) + ->assertJson(['message' => 'Task erfolgreich dem Event zugewiesen.']); + + $this->assertDatabaseHas('event_task', [ + 'task_id' => $task->id, + 'event_id' => $event->id, + ]); + } + + #[Test] + public function bulk_assign_tasks_to_event_works() + { + $tasks = Task::factory(3)->create([ + 'tenant_id' => $this->tenant->id, + 'priority' => 'medium', + ]); + $event = Event::factory()->create([ + 'tenant_id' => $this->tenant->id, + 'event_type_id' => 1, + ]); + + $response = $this->withHeaders(['Authorization' => 'Bearer ' . $this->token]) + ->postJson("/api/v1/tenant/tasks/bulk-assign-event/{$event->id}", [ + 'task_ids' => $tasks->pluck('id')->toArray(), + ]); + + $response->assertStatus(200) + ->assertJson(['message' => '3 Tasks dem Event zugewiesen.']); + + $this->assertEquals(3, $event->tasks()->count()); + } + + #[Test] + public function can_get_tasks_for_specific_event() + { + $event = Event::factory()->create([ + 'tenant_id' => $this->tenant->id, + 'event_type_id' => 1, + ]); + $eventTasks = Task::factory(2)->create([ + 'tenant_id' => $this->tenant->id, + 'priority' => 'medium', + ]); + $eventTasks->each(fn($task) => $task->assignedEvents()->attach($event->id)); + + Task::factory(3)->create([ + 'tenant_id' => $this->tenant->id, + 'priority' => 'medium', + ]); // Other tasks + + $response = $this->withHeaders(['Authorization' => 'Bearer ' . $this->token]) + ->getJson("/api/v1/tenant/tasks/event/{$event->id}"); + + $response->assertStatus(200) + ->assertJsonCount(2, 'data'); + } + + #[Test] + public function can_filter_tasks_by_collection() + { + $collection = TaskCollection::factory()->create([ + 'tenant_id' => $this->tenant->id, + ]); + $collectionTasks = Task::factory(2)->create([ + 'tenant_id' => $this->tenant->id, + 'priority' => 'medium', + ]); + $collectionTasks->each(fn($task) => $task->taskCollection()->associate($collection)); + + Task::factory(3)->create([ + 'tenant_id' => $this->tenant->id, + 'priority' => 'medium', + ]); // Other tasks + + $response = $this->withHeaders(['Authorization' => 'Bearer ' . $this->token]) + ->getJson("/api/v1/tenant/tasks?collection_id={$collection->id}"); + + $response->assertStatus(200) + ->assertJsonCount(2, 'data'); + } + + #[Test] + public function tasks_can_be_searched() + { + Task::factory()->create([ + 'tenant_id' => $this->tenant->id, + 'title' => 'First Task', + 'priority' => 'medium' + ]); + Task::factory()->create([ + 'tenant_id' => $this->tenant->id, + 'title' => 'Search Test', + 'priority' => 'medium' + ]); + Task::factory()->create([ + 'tenant_id' => $this->tenant->id, + 'title' => 'Another Task', + 'priority' => 'medium' + ]); + + $response = $this->withHeaders(['Authorization' => 'Bearer ' . $this->token]) + ->getJson('/api/v1/tenant/tasks?search=Search'); + + $response->assertStatus(200) + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.title', 'Search Test'); + } +} \ No newline at end of file diff --git a/tests/Feature/Tenant/TenantTestCase.php b/tests/Feature/Tenant/TenantTestCase.php new file mode 100644 index 0000000..710279f --- /dev/null +++ b/tests/Feature/Tenant/TenantTestCase.php @@ -0,0 +1,56 @@ +tenant = Tenant::factory()->create([ + 'name' => 'Test Tenant', + 'slug' => 'test-tenant', + ]); + + $this->tenantUser = User::factory()->create([ + 'name' => 'Test User', + 'email' => 'test@example.com', + 'tenant_id' => $this->tenant->id, + 'role' => 'admin', + ]); + + $this->token = 'mock-jwt-token-' . $this->tenant->id . '-' . time(); + } + + protected function authenticatedRequest($method, $uri, array $data = [], array $headers = []) + { + $headers['Authorization'] = 'Bearer ' . $this->token; + + // Temporarily override the middleware to skip auth and set tenant + $this->app['router']->pushMiddlewareToGroup('api', MockTenantMiddleware::class, 'mock-tenant'); + + return $this->withHeaders($headers)->json($method, $uri, $data); + } + + protected function mockTenantContext() + { + $this->actingAs($this->tenantUser); + + // Set tenant globally for tests + $this->app->instance('tenant', $this->tenant); + } +} \ No newline at end of file diff --git a/tests/Unit/TenantModelTest.php b/tests/Unit/TenantModelTest.php new file mode 100644 index 0000000..aef2e48 --- /dev/null +++ b/tests/Unit/TenantModelTest.php @@ -0,0 +1,132 @@ +create(); + Event::factory(3)->create(['tenant_id' => $tenant->id]); + + $this->assertCount(3, $tenant->events); + } + + /** @test */ + public function tenant_has_photos_through_events() + { + $tenant = Tenant::factory()->create(); + $event = Event::factory()->create(['tenant_id' => $tenant->id]); + Photo::factory(2)->create(['event_id' => $event->id]); + + $this->assertCount(2, $tenant->photos); + } + + /** @test */ + public function tenant_has_many_purchases() + { + $tenant = Tenant::factory()->create(); + PurchaseHistory::factory(2)->create(['tenant_id' => $tenant->id]); + + $this->assertCount(2, $tenant->purchases); + } + + /** @test */ + public function active_subscription_returns_true_if_not_expired() + { + $tenant = Tenant::factory()->create([ + 'subscription_tier' => 'pro', + 'subscription_expires_at' => now()->addDays(30), + ]); + + $this->assertTrue($tenant->active_subscription); + } + + /** @test */ + public function active_subscription_returns_false_if_expired() + { + $tenant = Tenant::factory()->create([ + 'subscription_tier' => 'pro', + 'subscription_expires_at' => now()->subDays(1), + ]); + + $this->assertFalse($tenant->active_subscription); + } + + /** @test */ + public function active_subscription_returns_false_if_no_subscription() + { + $tenant = Tenant::factory()->create([ + 'subscription_tier' => 'free', + 'subscription_expires_at' => null, + ]); + + $this->assertFalse($tenant->active_subscription); + } + + /** @test */ + public function can_decrement_credits() + { + $tenant = Tenant::factory()->create(['event_credits_balance' => 10]); + + $result = $tenant->decrementCredits(3); + + $this->assertTrue($result); + $this->assertEquals(7, $tenant->fresh()->event_credits_balance); + } + + /** @test */ + public function can_increment_credits() + { + $tenant = Tenant::factory()->create(['event_credits_balance' => 10]); + + $result = $tenant->incrementCredits(5); + + $this->assertTrue($result); + $this->assertEquals(15, $tenant->fresh()->event_credits_balance); + } + + /** @test */ + public function decrementing_credits_does_not_go_negative() + { + $tenant = Tenant::factory()->create(['event_credits_balance' => 2]); + + $result = $tenant->decrementCredits(5); + + $this->assertFalse($result); + $this->assertEquals(2, $tenant->fresh()->event_credits_balance); + } + + /** @test */ + public function settings_are_properly_cast_as_json() + { + $tenant = Tenant::factory()->create([ + 'settings' => json_encode(['theme' => 'dark', 'logo' => 'logo.png']) + ]); + + $this->assertIsArray($tenant->settings); + $this->assertEquals('dark', $tenant->settings['theme']); + } + + /** @test */ + public function features_are_cast_as_array() + { + $tenant = Tenant::factory()->create([ + 'features' => ['photo_likes' => true, 'analytics' => false] + ]); + + $this->assertIsArray($tenant->features); + $this->assertTrue($tenant->features['photo_likes']); + $this->assertFalse($tenant->features['analytics']); + } +} \ No newline at end of file