Implement multi-tenancy support with OAuth2 authentication for tenant admins, Stripe integration for event purchases and credits ledger, new Filament resources for event purchases, updated API routes and middleware for tenant isolation and token guarding, added factories/seeders/migrations for new models (Tenant, EventPurchase, OAuth entities, etc.), enhanced tests, and documentation updates. Removed outdated DemoAchievementsSeeder.

This commit is contained in:
2025-09-17 19:56:54 +02:00
parent 5fbb9cb240
commit 42d6e98dff
84 changed files with 6125 additions and 155 deletions

View File

@@ -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}"

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Exports;
use App\Models\EventPurchase;
use Filament\Actions\Exports\Exporter;
use Filament\Actions\Exports\Models\Export;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
class EventPurchaseExporter extends Exporter
{
public static function getModel(): string
{
return EventPurchase::class;
}
public static function getColumns(): array
{
return [
EventPurchase::raw('tenant.name'),
EventPurchase::raw('package_id'),
EventPurchase::raw('credits_added'),
EventPurchase::raw('price'),
EventPurchase::raw('platform'),
EventPurchase::raw('purchased_at'),
EventPurchase::raw('transaction_id'),
];
}
public static function getCompletedNotificationBody(Export $export): string
{
$body = "Your Event Purchases export has completed and is ready for download. {$export->successful_rows} purchases were exported.";
return $body;
}
}

View File

@@ -0,0 +1,211 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\EventPurchaseResource\Pages;
use App\Models\EventPurchase;
use App\Exports\EventPurchaseExporter;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Textarea;
use Filament\Schemas\Schema;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Actions\Action;
use Filament\Tables\Actions\BulkActionGroup;
use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Actions\ExportBulkAction;
use Filament\Tables\Actions\ViewAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\Filter;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
use Illuminate\Support\Facades\Log;
use BackedEnum;
use UnitEnum;
class EventPurchaseResource extends Resource
{
protected static ?string $model = EventPurchase::class;
public static function getNavigationIcon(): string
{
return 'heroicon-o-shopping-cart';
}
public static function getNavigationGroup(): string
{
return 'Billing';
}
protected static ?int $navigationSort = 10;
public static function form(Schema $schema): Schema
{
return $schema
->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'),
];
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\EventPurchaseResource\Pages;
use App\Filament\Resources\EventPurchaseResource;
use Filament\Resources\Pages\CreateRecord;
class CreateEventPurchase extends CreateRecord
{
protected static string $resource = EventPurchaseResource::class;
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Filament\Resources\EventPurchaseResource\Pages;
use App\Filament\Resources\EventPurchaseResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditEventPurchase extends EditRecord
{
protected static string $resource = EventPurchaseResource::class;
protected function getHeaderActions(): array
{
return [
Actions\ViewAction::make(),
Actions\DeleteAction::make(),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\EventPurchaseResource\Pages;
use App\Filament\Resources\EventPurchaseResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListEventPurchases extends ListRecords
{
protected static string $resource = EventPurchaseResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\EventPurchaseResource\Pages;
use App\Filament\Resources\EventPurchaseResource;
use Filament\Actions;
use Filament\Resources\Pages\ViewRecord;
class ViewEventPurchase extends ViewRecord
{
protected static string $resource = EventPurchaseResource::class;
protected function getHeaderActions(): array
{
return [
Actions\EditAction::make(),
];
}
}

View File

@@ -14,6 +14,11 @@ use Filament\Schemas\Schema;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Components\KeyValue;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\DateTimePicker;
use Filament\Tables\Columns\IconColumn;
use App\Filament\Resources\TenantResource\RelationManagers\PurchasesRelationManager;
use Filament\Resources\RelationManagers\RelationGroup;
use UnitEnum;
use BackedEnum;
@@ -50,6 +55,28 @@ class TenantResource extends Resource
->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(),

View File

@@ -0,0 +1,128 @@
<?php
namespace App\Filament\Resources\TenantResource\RelationManagers;
use Filament\Forms;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Textarea;
use Filament\Schemas\Schema;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Actions\BulkActionGroup;
use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Actions\ViewAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\Relation;
class PurchasesRelationManager extends RelationManager
{
protected static string $relationship = 'eventPurchases';
protected static ?string $title = 'Käufe';
public function form(Schema $form): Schema
{
return $form
->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(),
]),
]);
}
}

View File

@@ -0,0 +1,224 @@
<?php
namespace App\Http\Controllers\Api\Tenant;
use App\Http\Controllers\Controller;
use App\Http\Requests\Tenant\EventStoreRequest;
use Illuminate\Support\Str;
use App\Http\Resources\Tenant\EventResource;
use App\Models\Event;
use App\Models\Tenant;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Gate;
use Illuminate\Validation\ValidationException;
class EventController extends Controller
{
/**
* Display a listing of the tenant's events.
*/
public function index(Request $request): AnonymousResourceCollection
{
$tenantId = $request->attributes->get('tenant_id');
if (!$tenantId) {
throw ValidationException::withMessages([
'tenant_id' => 'Tenant ID not found in request context.',
]);
}
$query = Event::where('tenant_id', $tenantId)
->with(['eventType', 'photos'])
->orderBy('created_at', 'desc');
// Apply filters
if ($request->has('status')) {
$query->where('status', $request->status);
}
if ($request->has('type_id')) {
$query->where('event_type_id', $request->type_id);
}
// Pagination
$perPage = $request->get('per_page', 15);
$events = $query->paginate($perPage);
return EventResource::collection($events);
}
/**
* Store a newly created event in storage.
*/
public function store(EventStoreRequest $request): JsonResponse
{
$tenantId = $request->attributes->get('tenant_id');
// Check credits balance
$tenant = Tenant::findOrFail($tenantId);
if ($tenant->event_credits_balance <= 0) {
return response()->json([
'error' => 'Insufficient event credits. Please purchase more credits.',
], 402);
}
$validated = $request->validated();
$event = Event::create(array_merge($validated, [
'tenant_id' => $tenantId,
'status' => 'draft', // Default status
'slug' => $this->generateUniqueSlug($validated['name'], $tenantId),
]));
// Decrement credits
$tenant->decrement('event_credits_balance', 1);
$event->load(['eventType', 'tenant']);
return response()->json([
'message' => 'Event created successfully',
'data' => new EventResource($event),
], 201);
}
/**
* Display the specified event.
*/
public function show(Request $request, Event $event): JsonResponse
{
$tenantId = $request->attributes->get('tenant_id');
// Ensure event belongs to tenant
if ($event->tenant_id !== $tenantId) {
return response()->json(['error' => 'Event not found'], 404);
}
$event->load([
'eventType',
'photos' => fn ($query) => $query->with('likes')->latest(),
'tasks',
'tenant' => fn ($query) => $query->select('id', 'name', 'event_credits_balance')
]);
return response()->json([
'data' => new EventResource($event),
]);
}
/**
* Update the specified event in storage.
*/
public function update(EventStoreRequest $request, Event $event): JsonResponse
{
$tenantId = $request->attributes->get('tenant_id');
// Ensure event belongs to tenant
if ($event->tenant_id !== $tenantId) {
return response()->json(['error' => 'Event not found'], 404);
}
$validated = $request->validated();
// Update slug if name changed
if ($validated['name'] !== $event->name) {
$validated['slug'] = $this->generateUniqueSlug($validated['name'], $tenantId, $event->id);
}
$event->update($validated);
$event->load(['eventType', 'tenant']);
return response()->json([
'message' => 'Event updated successfully',
'data' => new EventResource($event),
]);
}
/**
* Remove the specified event from storage.
*/
public function destroy(Request $request, Event $event): JsonResponse
{
$tenantId = $request->attributes->get('tenant_id');
// Ensure event belongs to tenant
if ($event->tenant_id !== $tenantId) {
return response()->json(['error' => 'Event not found'], 404);
}
// Soft delete
$event->delete();
return response()->json([
'message' => 'Event deleted successfully',
]);
}
/**
* Bulk update event status (publish/unpublish)
*/
public function bulkUpdateStatus(Request $request): JsonResponse
{
$tenantId = $request->attributes->get('tenant_id');
$validated = $request->validate([
'event_ids' => 'required|array',
'event_ids.*' => 'exists:events,id',
'status' => 'required|in:draft,published,archived',
]);
$updatedCount = Event::whereIn('id', $validated['event_ids'])
->where('tenant_id', $tenantId)
->update(['status' => $validated['status']]);
return response()->json([
'message' => "{$updatedCount} events updated successfully",
'updated_count' => $updatedCount,
]);
}
/**
* Generate unique slug for event name
*/
private function generateUniqueSlug(string $name, int $tenantId, ?int $excludeId = null): string
{
$slug = Str::slug($name);
$originalSlug = $slug;
$counter = 1;
while (Event::where('slug', $slug)
->where('tenant_id', $tenantId)
->where('id', '!=', $excludeId)
->exists()) {
$slug = $originalSlug . '-' . $counter;
$counter++;
}
return $slug;
}
/**
* Search events by name or description
*/
public function search(Request $request): AnonymousResourceCollection
{
$tenantId = $request->attributes->get('tenant_id');
$query = $request->get('q', '');
if (strlen($query) < 2) {
return EventResource::collection(collect([]));
}
$events = Event::where('tenant_id', $tenantId)
->where(function ($q) use ($query) {
$q->where('name', 'like', "%{$query}%")
->orWhere('description', 'like', "%{$query}%");
})
->with('eventType')
->limit(10)
->get();
return EventResource::collection($events);
}
}

View File

@@ -0,0 +1,470 @@
<?php
namespace App\Http\Controllers\Api\Tenant;
use App\Http\Controllers\Controller;
use App\Http\Requests\Tenant\PhotoStoreRequest;
use App\Http\Resources\Tenant\PhotoResource;
use App\Models\Event;
use App\Models\Photo;
use App\Support\ImageHelper;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Illuminate\Support\Str;
class PhotoController extends Controller
{
/**
* Display a listing of the event's photos.
*/
public function index(Request $request, string $eventSlug): AnonymousResourceCollection
{
$tenantId = $request->attributes->get('tenant_id');
$event = Event::where('slug', $eventSlug)
->where('tenant_id', $tenantId)
->firstOrFail();
$query = Photo::where('event_id', $event->id)
->with(['likes', 'uploader'])
->orderBy('created_at', 'desc');
// Filters
if ($request->has('status')) {
$query->where('status', $request->status);
}
if ($request->has('user_id')) {
$query->where('uploader_id', $request->user_id);
}
$perPage = $request->get('per_page', 20);
$photos = $query->paginate($perPage);
return PhotoResource::collection($photos);
}
/**
* Store a newly uploaded photo.
*/
public function store(PhotoStoreRequest $request, string $eventSlug): JsonResponse
{
$tenantId = $request->attributes->get('tenant_id');
$event = Event::where('slug', $eventSlug)
->where('tenant_id', $tenantId)
->firstOrFail();
$validated = $request->validated();
$file = $request->file('photo');
if (!$file) {
throw ValidationException::withMessages([
'photo' => 'No photo file uploaded.',
]);
}
// Validate file type and size
$allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
if (!in_array($file->getMimeType(), $allowedTypes)) {
throw ValidationException::withMessages([
'photo' => 'Only JPEG, PNG, and WebP images are allowed.',
]);
}
if ($file->getSize() > 10 * 1024 * 1024) { // 10MB
throw ValidationException::withMessages([
'photo' => 'Photo size must be less than 10MB.',
]);
}
// Generate unique filename
$extension = $file->getClientOriginalExtension();
$filename = Str::uuid() . '.' . $extension;
$path = "events/{$eventSlug}/photos/{$filename}";
// Store original file
Storage::disk('public')->put($path, file_get_contents($file->getRealPath()));
// Generate thumbnail
$thumbnailPath = "events/{$eventSlug}/thumbnails/{$filename}";
$thumbnailRelative = ImageHelper::makeThumbnailOnDisk('public', $path, $thumbnailPath, 400);
if ($thumbnailRelative) {
$thumbnailPath = $thumbnailRelative;
}
// Create photo record
$photo = Photo::create([
'event_id' => $event->id,
'filename' => $filename,
'original_name' => $file->getClientOriginalName(),
'mime_type' => $file->getMimeType(),
'size' => $file->getSize(),
'path' => $path,
'thumbnail_path' => $thumbnailPath,
'width' => null, // To be filled by image processing
'height' => null,
'status' => 'pending', // Requires moderation
'uploader_id' => $request->user()->id ?? null,
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
]);
// Get image dimensions
list($width, $height) = getimagesize($file->getRealPath());
$photo->update(['width' => $width, 'height' => $height]);
$photo->load(['event', 'uploader']);
return response()->json([
'message' => 'Photo uploaded successfully. Awaiting moderation.',
'data' => new PhotoResource($photo),
'moderation_notice' => 'Your photo has been uploaded and will be reviewed shortly.',
], 201);
}
/**
* Display the specified photo.
*/
public function show(Request $request, string $eventSlug, Photo $photo): JsonResponse
{
$tenantId = $request->attributes->get('tenant_id');
$event = Event::where('slug', $eventSlug)
->where('tenant_id', $tenantId)
->firstOrFail();
if ($photo->event_id !== $event->id) {
return response()->json(['error' => 'Photo not found'], 404);
}
$photo->load(['event', 'uploader', 'likes']);
$photo->increment('view_count');
return response()->json([
'data' => new PhotoResource($photo),
]);
}
/**
* Update the specified photo (moderation or metadata).
*/
public function update(Request $request, string $eventSlug, Photo $photo): JsonResponse
{
$tenantId = $request->attributes->get('tenant_id');
$event = Event::where('slug', $eventSlug)
->where('tenant_id', $tenantId)
->firstOrFail();
if ($photo->event_id !== $event->id) {
return response()->json(['error' => 'Photo not found'], 404);
}
$validated = $request->validate([
'status' => ['sometimes', 'in:pending,approved,rejected'],
'moderation_notes' => ['sometimes', 'required_if:status,rejected', 'string', 'max:1000'],
'caption' => ['sometimes', 'string', 'max:500'],
'alt_text' => ['sometimes', 'string', 'max:255'],
]);
// Only tenant admins can moderate
if (isset($validated['status']) && $request->user()->role !== 'admin') {
return response()->json(['error' => 'Insufficient permissions for moderation'], 403);
}
$photo->update($validated);
if ($validated['status'] ?? null === 'approved') {
$photo->load(['event', 'uploader']);
// Trigger event for new photo notification
// event(new \App\Events\PhotoApproved($photo)); // Implement later
}
return response()->json([
'message' => 'Photo updated successfully',
'data' => new PhotoResource($photo),
]);
}
/**
* Remove the specified photo from storage.
*/
public function destroy(Request $request, string $eventSlug, Photo $photo): JsonResponse
{
$tenantId = $request->attributes->get('tenant_id');
$event = Event::where('slug', $eventSlug)
->where('tenant_id', $tenantId)
->firstOrFail();
if ($photo->event_id !== $event->id) {
return response()->json(['error' => 'Photo not found'], 404);
}
// Delete from storage
Storage::disk('public')->delete([
$photo->path,
$photo->thumbnail_path,
]);
// Delete record and likes
DB::transaction(function () use ($photo) {
$photo->likes()->delete();
$photo->delete();
});
return response()->json([
'message' => 'Photo deleted successfully',
]);
}
/**
* Bulk approve photos (admin only)
*/
public function bulkApprove(Request $request, string $eventSlug): JsonResponse
{
$tenantId = $request->attributes->get('tenant_id');
$event = Event::where('slug', $eventSlug)
->where('tenant_id', $tenantId)
->firstOrFail();
$request->validate([
'photo_ids' => 'required|array',
'photo_ids.*' => 'exists:photos,id',
'moderation_notes' => 'nullable|string|max:1000',
]);
$photoIds = $request->photo_ids;
$approvedCount = Photo::whereIn('id', $photoIds)
->where('event_id', $event->id)
->where('status', 'pending')
->update([
'status' => 'approved',
'moderation_notes' => $request->moderation_notes,
'moderated_at' => now(),
'moderated_by' => $request->user()->id,
]);
// Load approved photos for response
$photos = Photo::whereIn('id', $photoIds)
->where('event_id', $event->id)
->with(['uploader', 'event'])
->get();
// Trigger events
foreach ($photos as $photo) {
// event(new \App\Events\PhotoApproved($photo)); // Implement later
}
return response()->json([
'message' => "{$approvedCount} photos approved successfully",
'approved_count' => $approvedCount,
'data' => PhotoResource::collection($photos),
]);
}
/**
* Bulk reject photos (admin only)
*/
public function bulkReject(Request $request, string $eventSlug): JsonResponse
{
$tenantId = $request->attributes->get('tenant_id');
$event = Event::where('slug', $eventSlug)
->where('tenant_id', $tenantId)
->firstOrFail();
$request->validate([
'photo_ids' => 'required|array',
'photo_ids.*' => 'exists:photos,id',
'moderation_notes' => 'required|string|max:1000',
]);
$photoIds = $request->photo_ids;
$rejectedCount = Photo::whereIn('id', $photoIds)
->where('event_id', $event->id)
->where('status', 'pending')
->update([
'status' => 'rejected',
'moderation_notes' => $request->moderation_notes,
'moderated_at' => now(),
'moderated_by' => $request->user()->id,
]);
// Optionally delete rejected photos from storage
$rejectedPhotos = Photo::whereIn('id', $photoIds)
->where('event_id', $event->id)
->get();
foreach ($rejectedPhotos as $photo) {
Storage::disk('public')->delete([
$photo->path,
$photo->thumbnail_path,
]);
}
return response()->json([
'message' => "{$rejectedCount} photos rejected and deleted",
'rejected_count' => $rejectedCount,
]);
}
/**
* Get photos for moderation (admin only)
*/
public function forModeration(Request $request, string $eventSlug): AnonymousResourceCollection
{
$tenantId = $request->attributes->get('tenant_id');
$event = Event::where('slug', $eventSlug)
->where('tenant_id', $tenantId)
->firstOrFail();
$photos = Photo::where('event_id', $event->id)
->where('status', 'pending')
->with(['uploader', 'event'])
->orderBy('created_at', 'desc')
->paginate($request->get('per_page', 20));
return PhotoResource::collection($photos);
}
/**
* Get upload statistics for event
*/
public function stats(Request $request, string $eventSlug): JsonResponse
{
$tenantId = $request->attributes->get('tenant_id');
$event = Event::where('slug', $eventSlug)
->where('tenant_id', $tenantId)
->firstOrFail();
$totalPhotos = Photo::where('event_id', $event->id)->count();
$pendingPhotos = Photo::where('event_id', $event->id)->where('status', 'pending')->count();
$approvedPhotos = Photo::where('event_id', $event->id)->where('status', 'approved')->count();
$totalLikes = DB::table('photo_likes')->whereIn('photo_id',
Photo::where('event_id', $event->id)->pluck('id')
)->count();
$totalStorage = Photo::where('event_id', $event->id)->sum('size');
$uniqueUploaders = Photo::where('event_id', $event->id)
->select('uploader_id')
->distinct()
->count('uploader_id');
$recentUploads = Photo::where('event_id', $event->id)
->where('created_at', '>=', now()->subDays(7))
->count();
return response()->json([
'event_id' => $event->id,
'total_photos' => $totalPhotos,
'pending_photos' => $pendingPhotos,
'approved_photos' => $approvedPhotos,
'total_likes' => $totalLikes,
'total_storage_bytes' => $totalStorage,
'total_storage_mb' => round($totalStorage / (1024 * 1024), 2),
'unique_uploaders' => $uniqueUploaders,
'recent_uploads_7d' => $recentUploads,
'storage_quota_remaining' => $event->tenant->storage_quota - $totalStorage,
'quota_percentage_used' => min(100, round(($totalStorage / $event->tenant->storage_quota) * 100, 1)),
]);
}
/**
* Generate presigned S3 URL for direct upload (alternative to local storage)
*/
public function presignedUpload(Request $request, string $eventSlug): JsonResponse
{
$tenantId = $request->attributes->get('tenant_id');
$event = Event::where('slug', $eventSlug)
->where('tenant_id', $tenantId)
->firstOrFail();
$request->validate([
'filename' => 'required|string|max:255',
'content_type' => 'required|string|in:image/jpeg,image/png,image/webp',
]);
// Generate unique filename
$extension = pathinfo($request->filename, PATHINFO_EXTENSION);
$filename = Str::uuid() . '.' . $extension;
$path = "events/{$eventSlug}/pending/{$filename}";
// For local storage, return direct upload endpoint
// For S3, use Storage::disk('s3')->temporaryUrl() or presigned URL
$uploadUrl = url("/api/v1/tenant/events/{$eventSlug}/upload-direct");
$fields = [
'event_id' => $event->id,
'filename' => $filename,
'original_name' => $request->filename,
];
return response()->json([
'upload_url' => $uploadUrl,
'fields' => $fields,
'path' => $path,
'max_size' => 10 * 1024 * 1024, // 10MB
'allowed_types' => ['image/jpeg', 'image/png', 'image/webp'],
]);
}
/**
* Direct upload endpoint for presigned uploads
*/
public function uploadDirect(Request $request, string $eventSlug): JsonResponse
{
$tenantId = $request->attributes->get('tenant_id');
$event = Event::where('slug', $eventSlug)
->where('tenant_id', $tenantId)
->firstOrFail();
$request->validate([
'event_id' => 'required|exists:events,id',
'filename' => 'required|string',
'original_name' => 'required|string',
'photo' => 'required|image|mimes:jpeg,png,webp|max:10240', // 10MB
]);
if ($request->event_id !== $event->id) {
return response()->json(['error' => 'Invalid event ID'], 400);
}
$file = $request->file('photo');
$filename = $request->filename;
$path = "events/{$eventSlug}/photos/{$filename}";
// Store file
Storage::disk('public')->put($path, file_get_contents($file->getRealPath()));
// Generate thumbnail
$thumbnailPath = "events/{$eventSlug}/thumbnails/{$filename}";
$thumbnailRelative = ImageHelper::makeThumbnailOnDisk('public', $path, $thumbnailPath, 400);
if ($thumbnailRelative) {
$thumbnailPath = $thumbnailRelative;
}
// Create photo record
$photo = Photo::create([
'event_id' => $event->id,
'filename' => $filename,
'original_name' => $request->original_name,
'mime_type' => $file->getMimeType(),
'size' => $file->getSize(),
'path' => $path,
'thumbnail_path' => $thumbnailPath,
'status' => 'pending',
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
]);
// Get dimensions
list($width, $height) = getimagesize($file->getRealPath());
$photo->update(['width' => $width, 'height' => $height]);
return response()->json([
'message' => 'Upload successful. Awaiting moderation.',
'photo_id' => $photo->id,
'status' => 'pending',
], 201);
}
}

View File

@@ -0,0 +1,135 @@
<?php
namespace App\Http\Controllers\Api\Tenant;
use App\Http\Controllers\Controller;
use App\Http\Requests\Tenant\SettingsStoreRequest;
use App\Models\Tenant;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
class SettingsController extends Controller
{
/**
* Get the tenant's settings.
*
* @param Request $request
* @return JsonResponse
*/
public function index(Request $request): JsonResponse
{
$tenant = $request->tenant;
return response()->json([
'message' => 'Settings erfolgreich abgerufen.',
'data' => [
'id' => $tenant->id,
'settings' => $tenant->settings ?? [],
'updated_at' => $tenant->settings_updated_at?->toISOString(),
],
]);
}
/**
* Update the tenant's settings.
*
* @param SettingsStoreRequest $request
* @return JsonResponse
*/
public function update(SettingsStoreRequest $request): JsonResponse
{
$tenant = $request->tenant;
// Merge new settings with existing ones
$currentSettings = $tenant->settings ?? [];
$newSettings = array_merge($currentSettings, $request->validated()['settings']);
$tenant->update([
'settings' => $newSettings,
'settings_updated_at' => now(),
]);
return response()->json([
'message' => 'Settings erfolgreich aktualisiert.',
'data' => [
'id' => $tenant->id,
'settings' => $newSettings,
'updated_at' => now()->toISOString(),
],
]);
}
/**
* Reset tenant settings to defaults.
*
* @param Request $request
* @return JsonResponse
*/
public function reset(Request $request): JsonResponse
{
$tenant = $request->tenant;
$defaultSettings = [
'branding' => [
'logo_url' => null,
'primary_color' => '#3B82F6',
'secondary_color' => '#1F2937',
'font_family' => 'Inter, sans-serif',
],
'features' => [
'photo_likes_enabled' => true,
'event_checklist' => true,
'custom_domain' => false,
'advanced_analytics' => false,
],
'custom_domain' => null,
'contact_email' => $tenant->contact_email,
'event_default_type' => 'general',
];
$tenant->update([
'settings' => $defaultSettings,
'settings_updated_at' => now(),
]);
return response()->json([
'message' => 'Settings auf Standardwerte zurückgesetzt.',
'data' => [
'id' => $tenant->id,
'settings' => $defaultSettings,
'updated_at' => now()->toISOString(),
],
]);
}
/**
* Validate custom domain availability.
*
* @param Request $request
* @return JsonResponse
*/
public function validateDomain(Request $request): JsonResponse
{
$domain = $request->input('domain');
if (!$domain) {
return response()->json(['error' => 'Domain ist erforderlich.'], 400);
}
// Simple validation - in production, check DNS records or database uniqueness
if (!preg_match('/^[a-zA-Z0-9][a-zA-Z0-9-]{1,61}[a-zA-Z0-9]\.[a-zA-Z]{2,}$/', $domain)) {
return response()->json([
'available' => false,
'message' => 'Ungültiges Domain-Format.',
]);
}
// Check if domain is already taken by another tenant
$taken = Tenant::where('custom_domain', $domain)->where('id', '!=', $request->tenant->id)->exists();
return response()->json([
'available' => !$taken,
'message' => $taken ? 'Domain ist bereits vergeben.' : 'Domain ist verfügbar.',
]);
}
}

View File

@@ -0,0 +1,242 @@
<?php
namespace App\Http\Controllers\Api\Tenant;
use App\Http\Controllers\Controller;
use App\Http\Requests\Tenant\TaskStoreRequest;
use App\Http\Requests\Tenant\TaskUpdateRequest;
use App\Http\Resources\Tenant\TaskResource;
use App\Models\Task;
use App\Models\TaskCollection;
use App\Models\Event;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Http\JsonResponse;
class TaskController extends Controller
{
/**
* Display a listing of the tenant's tasks.
*
* @param Request $request
* @return AnonymousResourceCollection
*/
public function index(Request $request): AnonymousResourceCollection
{
$query = Task::where('tenant_id', $request->tenant->id)
->with(['taskCollection', 'assignedEvents'])
->orderBy('created_at', 'desc');
// Search and filters
if ($search = $request->get('search')) {
$query->where('title', 'like', "%{$search}%")
->orWhere('description', 'like', "%{$search}%");
}
if ($collectionId = $request->get('collection_id')) {
$query->whereHas('taskCollection', fn($q) => $q->where('id', $collectionId));
}
if ($eventId = $request->get('event_id')) {
$query->whereHas('assignedEvents', fn($q) => $q->where('id', $eventId));
}
$perPage = $request->get('per_page', 15);
$tasks = $query->paginate($perPage);
return TaskResource::collection($tasks);
}
/**
* Store a newly created task in storage.
*
* @param TaskStoreRequest $request
* @return JsonResponse
*/
public function store(TaskStoreRequest $request): JsonResponse
{
$task = Task::create(array_merge($request->validated(), [
'tenant_id' => $request->tenant->id,
]));
if ($collectionId = $request->input('collection_id')) {
$task->taskCollection()->associate(TaskCollection::findOrFail($collectionId));
$task->save();
}
$task->load(['taskCollection', 'assignedEvents']);
return response()->json([
'message' => 'Task erfolgreich erstellt.',
'data' => new TaskResource($task),
], 201);
}
/**
* Display the specified task.
*
* @param Request $request
* @param Task $task
* @return JsonResponse
*/
public function show(Request $request, Task $task): JsonResponse
{
if ($task->tenant_id !== $request->tenant->id) {
abort(404, 'Task nicht gefunden.');
}
$task->load(['taskCollection', 'assignedEvents']);
return response()->json(new TaskResource($task));
}
/**
* Update the specified task in storage.
*
* @param TaskUpdateRequest $request
* @param Task $task
* @return JsonResponse
*/
public function update(TaskUpdateRequest $request, Task $task): JsonResponse
{
if ($task->tenant_id !== $request->tenant->id) {
abort(404, 'Task nicht gefunden.');
}
$task->update($request->validated());
if ($collectionId = $request->input('collection_id')) {
$task->taskCollection()->associate(TaskCollection::findOrFail($collectionId));
$task->save();
}
$task->load(['taskCollection', 'assignedEvents']);
return response()->json([
'message' => 'Task erfolgreich aktualisiert.',
'data' => new TaskResource($task),
]);
}
/**
* Remove the specified task from storage.
*
* @param Request $request
* @param Task $task
* @return JsonResponse
*/
public function destroy(Request $request, Task $task): JsonResponse
{
if ($task->tenant_id !== $request->tenant->id) {
abort(404, 'Task nicht gefunden.');
}
$task->delete();
return response()->json([
'message' => 'Task erfolgreich gelöscht.',
]);
}
/**
* Assign task to an event.
*
* @param Request $request
* @param Task $task
* @param Event $event
* @return JsonResponse
*/
public function assignToEvent(Request $request, Task $task, Event $event): JsonResponse
{
if ($task->tenant_id !== $request->tenant->id || $event->tenant_id !== $request->tenant->id) {
abort(404);
}
if ($task->assignedEvents()->where('event_id', $event->id)->exists()) {
return response()->json(['message' => 'Task ist bereits diesem Event zugewiesen.'], 409);
}
$task->assignedEvents()->attach($event->id);
return response()->json([
'message' => 'Task erfolgreich dem Event zugewiesen.',
]);
}
/**
* Bulk assign tasks to an event.
*
* @param Request $request
* @param Event $event
* @return JsonResponse
*/
public function bulkAssignToEvent(Request $request, Event $event): JsonResponse
{
if ($event->tenant_id !== $request->tenant->id) {
abort(404);
}
$taskIds = $request->input('task_ids', []);
if (empty($taskIds)) {
return response()->json(['error' => 'Keine Task-IDs angegeben.'], 400);
}
$tasks = Task::whereIn('id', $taskIds)
->where('tenant_id', $request->tenant->id)
->get();
$attached = 0;
foreach ($tasks as $task) {
if (!$task->assignedEvents()->where('event_id', $event->id)->exists()) {
$task->assignedEvents()->attach($event->id);
$attached++;
}
}
return response()->json([
'message' => "{$attached} Tasks dem Event zugewiesen.",
]);
}
/**
* Get tasks for a specific event.
*
* @param Request $request
* @param Event $event
* @return AnonymousResourceCollection
*/
public function forEvent(Request $request, Event $event): AnonymousResourceCollection
{
if ($event->tenant_id !== $request->tenant->id) {
abort(404);
}
$tasks = Task::whereHas('assignedEvents', fn($q) => $q->where('event_id', $event->id))
->with(['taskCollection'])
->orderBy('created_at', 'desc')
->paginate($request->get('per_page', 15));
return TaskResource::collection($tasks);
}
/**
* Get tasks from a specific collection.
*
* @param Request $request
* @param TaskCollection $collection
* @return AnonymousResourceCollection
*/
public function fromCollection(Request $request, TaskCollection $collection): AnonymousResourceCollection
{
if ($collection->tenant_id !== $request->tenant->id) {
abort(404);
}
$tasks = $collection->tasks()
->with(['assignedEvents'])
->orderBy('created_at', 'desc')
->paginate($request->get('per_page', 15));
return TaskResource::collection($tasks);
}
}

View File

@@ -0,0 +1,376 @@
<?php
namespace App\Http\Controllers;
use App\Models\OAuthClient;
use App\Models\OAuthCode;
use App\Models\RefreshToken;
use App\Models\Tenant;
use App\Models\TenantToken;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use GuzzleHttp\Client;
use Illuminate\Support\Facades\Log;
class OAuthController extends Controller
{
/**
* Authorize endpoint - PKCE flow
*/
public function authorize(Request $request)
{
$validator = Validator::make($request->all(), [
'client_id' => 'required|string',
'redirect_uri' => 'required|url',
'scope' => 'required|string',
'state' => 'nullable|string',
'code_challenge' => 'required|string',
'code_challenge_method' => 'required|in:S256,plain',
]);
if ($validator->fails()) {
return $this->errorResponse('Invalid request parameters', 400, $validator->errors());
}
$client = OAuthClient::where('client_id', $request->client_id)
->where('is_active', true)
->first();
if (!$client) {
return $this->errorResponse('Invalid client', 401);
}
// Validate redirect URI
$redirectUris = is_array($client->redirect_uris) ? $client->redirect_uris : json_decode($client->redirect_uris, true);
if (!in_array($request->redirect_uri, $redirectUris)) {
return $this->errorResponse('Invalid redirect URI', 400);
}
// Validate scopes
$requestedScopes = explode(' ', $request->scope);
$availableScopes = is_array($client->scopes) ? array_keys($client->scopes) : [];
$validScopes = array_intersect($requestedScopes, $availableScopes);
if (count($validScopes) !== count($requestedScopes)) {
return $this->errorResponse('Invalid scopes requested', 400);
}
// Generate authorization code (PKCE validated later)
$code = Str::random(40);
$codeId = Str::uuid();
// Store code in Redis with 5min TTL
Cache::put("oauth_code:{$codeId}", [
'code' => $code,
'client_id' => $request->client_id,
'redirect_uri' => $request->redirect_uri,
'scopes' => $validScopes,
'state' => $request->state ?? null,
'code_challenge' => $request->code_challenge,
'code_challenge_method' => $request->code_challenge_method,
'expires_at' => now()->addMinutes(5),
], now()->addMinutes(5));
// Save to DB for persistence
OAuthCode::create([
'id' => $codeId,
'code' => Hash::make($code),
'client_id' => $request->client_id,
'scopes' => json_encode($validScopes),
'state' => $request->state ?? null,
'expires_at' => now()->addMinutes(5),
]);
$redirectUrl = $request->redirect_uri . '?' . http_build_query([
'code' => $code,
'state' => $request->state ?? '',
]);
return redirect($redirectUrl);
}
/**
* Token endpoint - Code exchange for access/refresh tokens
*/
public function token(Request $request)
{
$validator = Validator::make($request->all(), [
'grant_type' => 'required|in:authorization_code',
'code' => 'required|string',
'client_id' => 'required|string',
'redirect_uri' => 'required|url',
'code_verifier' => 'required|string', // PKCE
]);
if ($validator->fails()) {
return $this->errorResponse('Invalid request parameters', 400, $validator->errors());
}
// Find the code in cache/DB
$cachedCode = Cache::get($request->code);
if (!$cachedCode) {
return $this->errorResponse('Invalid authorization code', 400);
}
$codeId = array_search($request->code, Cache::get($request->code)['code'] ?? []);
if (!$codeId) {
$oauthCode = OAuthCode::where('code', 'LIKE', '%' . $request->code . '%')->first();
if (!$oauthCode || !Hash::check($request->code, $oauthCode->code)) {
return $this->errorResponse('Invalid authorization code', 400);
}
$cachedCode = $oauthCode->toArray();
$codeId = $oauthCode->id;
}
// Validate client
$client = OAuthClient::where('client_id', $request->client_id)->first();
if (!$client || !$client->is_active) {
return $this->errorResponse('Invalid client', 401);
}
// PKCE validation
if ($cachedCode['code_challenge_method'] === 'S256') {
$expectedChallenge = $this->base64url_encode(hash('sha256', $request->code_verifier, true));
if (!hash_equals($expectedChallenge, $cachedCode['code_challenge'])) {
return $this->errorResponse('Invalid code verifier', 400);
}
} else {
if (!hash_equals($request->code_verifier, $cachedCode['code'])) {
return $this->errorResponse('Invalid code verifier', 400);
}
}
// Validate redirect URI
if ($request->redirect_uri !== $cachedCode['redirect_uri']) {
return $this->errorResponse('Invalid redirect URI', 400);
}
// Code used - delete/invalidate
Cache::forget("oauth_code:{$codeId}");
OAuthCode::where('id', $codeId)->delete();
// Create tenant token (assuming client is tied to tenant - adjust as needed)
$tenant = Tenant::where('id', $client->tenant_id ?? 1)->first(); // Default tenant or from client
if (!$tenant) {
return $this->errorResponse('Tenant not found', 404);
}
// Generate JWT Access Token (RS256)
$privateKey = file_get_contents(storage_path('app/private.key')); // Ensure keys exist
if (!$privateKey) {
$this->generateKeyPair();
$privateKey = file_get_contents(storage_path('app/private.key'));
}
$accessToken = $this->generateJWT($tenant->id, $cachedCode['scopes'], 'access', 3600); // 1h
$refreshTokenId = Str::uuid();
$refreshToken = Str::random(60);
// Store refresh token
RefreshToken::create([
'id' => $refreshTokenId,
'token' => Hash::make($refreshToken),
'tenant_id' => $tenant->id,
'client_id' => $request->client_id,
'scopes' => json_encode($cachedCode['scopes']),
'expires_at' => now()->addDays(30),
'ip_address' => $request->ip(),
]);
return response()->json([
'token_type' => 'Bearer',
'access_token' => $accessToken,
'refresh_token' => $refreshToken,
'expires_in' => 3600,
'scope' => implode(' ', $cachedCode['scopes']),
]);
}
/**
* Get tenant info
*/
public function me(Request $request)
{
$user = $request->user();
if (!$user) {
return $this->errorResponse('Unauthenticated', 401);
}
$tenant = $user->tenant;
if (!$tenant) {
return $this->errorResponse('Tenant not found', 404);
}
return response()->json([
'tenant_id' => $tenant->id,
'name' => $tenant->name,
'slug' => $tenant->slug,
'event_credits_balance' => $tenant->event_credits_balance,
'subscription_tier' => $tenant->subscription_tier,
'subscription_expires_at' => $tenant->subscription_expires_at,
'features' => $tenant->features,
'scopes' => $request->user()->token()->abilities,
]);
}
/**
* Generate JWT token
*/
private function generateJWT($tenantId, $scopes, $type, $expiresIn)
{
$payload = [
'iss' => url('/'),
'aud' => 'fotospiel-api',
'iat' => time(),
'nbf' => time(),
'exp' => time() + $expiresIn,
'sub' => $tenantId,
'scopes' => $scopes,
'type' => $type,
];
$publicKey = file_get_contents(storage_path('app/public.key'));
$privateKey = file_get_contents(storage_path('app/private.key'));
if (!$publicKey || !$privateKey) {
$this->generateKeyPair();
$publicKey = file_get_contents(storage_path('app/public.key'));
$privateKey = file_get_contents(storage_path('app/private.key'));
}
return JWT::encode($payload, $privateKey, 'RS256', null, null, null, ['kid' => 'fotospiel-jwt']);
}
/**
* Generate RSA key pair for JWT
*/
private function generateKeyPair()
{
$config = [
"digest_alg" => OPENSSL_ALGO_SHA256,
"private_key_bits" => 2048,
"private_key_type" => OPENSSL_KEYTYPE_RSA,
];
$res = openssl_pkey_new($config);
if (!$res) {
throw new \Exception('Failed to generate key pair');
}
// Get private key
openssl_pkey_export($res, $privateKey);
file_put_contents(storage_path('app/private.key'), $privateKey);
// Get public key
$pubKey = openssl_pkey_get_details($res);
file_put_contents(storage_path('app/public.key'), $pubKey["key"]);
}
/**
* Error response helper
*/
private function errorResponse($message, $status = 400, $errors = null)
{
$response = ['error' => $message];
if ($errors) {
$response['errors'] = $errors;
}
return response()->json($response, $status);
}
/**
* Helper function for base64url encoding
*/
private function base64url_encode($data)
{
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
/**
* Stripe Connect OAuth - Start connection
*/
public function stripeConnect(Request $request)
{
$tenant = $request->user()->tenant;
if (!$tenant) {
return response()->json(['error' => 'Tenant not found'], 404);
}
$state = Str::random(40);
session(['stripe_state' => $state, 'tenant_id' => $tenant->id]);
Cache::put("stripe_connect_state:{$tenant->id}", $state, now()->addMinutes(10));
$clientId = config('services.stripe.connect_client_id');
$redirectUri = url('/api/v1/oauth/stripe-callback');
$scopes = 'read_write_payments transfers';
$authUrl = "https://connect.stripe.com/oauth/authorize?response_type=code&client_id={$clientId}&scope={$scopes}&state={$state}&redirect_uri=" . urlencode($redirectUri);
return redirect($authUrl);
}
/**
* Stripe Connect Callback
*/
public function stripeCallback(Request $request)
{
$code = $request->get('code');
$state = $request->get('state');
$error = $request->get('error');
if ($error) {
return redirect('/admin')->with('error', 'Stripe connection failed: ' . $error);
}
if (!$code || !$state) {
return redirect('/admin')->with('error', 'Invalid callback parameters');
}
// Validate state
$sessionState = session('stripe_state');
if (!hash_equals($state, $sessionState)) {
return redirect('/admin')->with('error', 'Invalid state parameter');
}
$client = new Client();
$clientId = config('services.stripe.connect_client_id');
$secret = config('services.stripe.connect_secret');
$redirectUri = url('/api/v1/oauth/stripe-callback');
try {
$response = $client->post('https://connect.stripe.com/oauth/token', [
'form_params' => [
'grant_type' => 'authorization_code',
'client_id' => $clientId,
'client_secret' => $secret,
'code' => $code,
'redirect_uri' => $redirectUri,
],
]);
$tokenData = json_decode($response->getBody(), true);
if (!isset($tokenData['stripe_user_id'])) {
return redirect('/admin')->with('error', 'Failed to connect Stripe account');
}
$tenant = Tenant::find(session('tenant_id'));
if ($tenant) {
$tenant->update(['stripe_account_id' => $tokenData['stripe_user_id']]);
}
session()->forget(['stripe_state', 'tenant_id']);
return redirect('/admin')->with('success', 'Stripe account connected successfully');
} catch (\Exception $e) {
Log::error('Stripe OAuth error: ' . $e->getMessage());
return redirect('/admin')->with('error', 'Connection error: ' . $e->getMessage());
}
}
}

View File

@@ -0,0 +1,91 @@
<?php
namespace App\Http\Controllers;
use App\Models\EventPurchase;
use App\Models\Tenant;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class StripeWebhookController extends Controller
{
public function handle(Request $request)
{
$payload = $request->getContent();
$sig = $request->header('Stripe-Signature');
$secret = config('services.stripe.webhook');
if (!$secret || !$sig) {
abort(400, 'Missing signature');
}
$expectedSig = 'v1=' . hash_hmac('sha256', $payload, $secret);
if (!hash_equals($expectedSig, $sig)) {
abort(400, 'Invalid signature');
}
$event = json_decode($payload, true);
if (json_last_error() !== JSON_ERROR_NONE) {
Log::error('Invalid JSON in Stripe webhook: ' . json_last_error_msg());
return response('', 200);
}
if ($event['type'] === 'checkout.session.completed') {
$session = $event['data']['object'];
$receiptId = $session['id'];
// Idempotency check
if (EventPurchase::where('external_receipt_id', $receiptId)->exists()) {
return response('', 200);
}
$tenantId = $session['metadata']['tenant_id'] ?? null;
if (!$tenantId) {
Log::warning('No tenant_id in Stripe metadata', ['receipt_id' => $receiptId]);
// Dispatch job for retry or manual resolution
\App\Jobs\ValidateStripeWebhookJob::dispatch($payload, $sig);
return response('', 200);
}
$tenant = Tenant::find($tenantId);
if (!$tenant) {
Log::error('Tenant not found for Stripe webhook', ['tenant_id' => $tenantId]);
\App\Jobs\ValidateStripeWebhookJob::dispatch($payload, $sig);
return response('', 200);
}
$amount = $session['amount_total'] / 100;
$currency = $session['currency'];
$eventsPurchased = (int) ($session['metadata']['events_purchased'] ?? 1);
DB::transaction(function () use ($tenant, $amount, $currency, $eventsPurchased, $receiptId) {
$purchase = EventPurchase::create([
'tenant_id' => $tenant->id,
'events_purchased' => $eventsPurchased,
'amount' => $amount,
'currency' => $currency,
'provider' => 'stripe',
'external_receipt_id' => $receiptId,
'status' => 'completed',
'purchased_at' => now(),
]);
$tenant->incrementCredits($eventsPurchased, 'purchase', null, $purchase->id);
});
Log::info('Processed Stripe purchase', ['receipt_id' => $receiptId, 'tenant_id' => $tenantId]);
} else {
// For other event types, log or dispatch job if needed
Log::info('Unhandled Stripe event', ['type' => $event['type']]);
// Optionally dispatch job for processing other events
\App\Jobs\ValidateStripeWebhookJob::dispatch($payload, $sig);
}
return response('', 200);
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace App\Http\Controllers\Tenant;
use App\Http\Controllers\Controller;
use App\Http\Resources\Tenant\CreditLedgerResource;
use App\Http\Resources\Tenant\EventPurchaseResource;
use App\Models\EventCreditsLedger;
use App\Models\EventPurchase;
use App\Models\Tenant;
use Illuminate\Http\Request;
class CreditController extends Controller
{
public function balance(Request $request)
{
$user = $request->user();
if (!$user) {
return response()->json(['message' => 'Unauthenticated'], 401);
}
$tenant = Tenant::findOrFail($user->tenant_id);
return response()->json([
'balance' => $tenant->event_credits_balance,
'free_event_granted_at' => $tenant->free_event_granted_at,
]);
}
public function ledger(Request $request)
{
$user = $request->user();
if (!$user) {
return response()->json(['message' => 'Unauthenticated'], 401);
}
$tenant = Tenant::findOrFail($user->tenant_id);
$ledgers = EventCreditsLedger::where('tenant_id', $tenant->id)
->orderBy('created_at', 'desc')
->paginate(20);
return CreditLedgerResource::collection($ledgers);
}
public function history(Request $request)
{
$user = $request->user();
if (!$user) {
return response()->json(['message' => 'Unauthenticated'], 401);
}
$tenant = Tenant::findOrFail($user->tenant_id);
$purchases = EventPurchase::where('tenant_id', $tenant->id)
->orderBy('purchased_at', 'desc')
->paginate(20);
return EventPurchaseResource::collection($purchases);
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
use App\Models\Tenant;
use App\Models\Event;
class CreditCheckMiddleware
{
public function handle(Request $request, Closure $next): Response
{
if ($request->isMethod('post') && $request->routeIs('api.v1.tenant.events.store')) {
$user = $request->user();
if (!$user) {
return response()->json(['message' => 'Unauthenticated'], 401);
}
$tenant = Tenant::findOrFail($user->tenant_id);
if ($tenant->event_credits_balance < 1) {
return response()->json(['message' => 'Insufficient event credits'], 422);
}
$request->merge(['tenant' => $tenant]);
} elseif (($request->isMethod('put') || $request->isMethod('patch')) && $request->routeIs('api.v1.tenant.events.update')) {
$eventSlug = $request->route('event');
$event = Event::where('slug', $eventSlug)->firstOrFail();
$tenant = $event->tenant;
// For update, no credit check needed (already consumed on create)
$request->merge(['tenant' => $tenant]);
}
return $next($request);
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class TenantIsolation
{
/**
* Handle an incoming request.
*/
public function handle(Request $request, Closure $next)
{
$tenantId = $request->attributes->get('tenant_id');
if (!$tenantId) {
return response()->json(['error' => 'Tenant ID not found in token'], 401);
}
// Get the tenant from request (query param, route param, or header)
$requestTenantId = $this->getTenantIdFromRequest($request);
if ($requestTenantId && $requestTenantId != $tenantId) {
return response()->json(['error' => 'Tenant isolation violation'], 403);
}
// Set tenant context for query scoping
DB::statement("SET @tenant_id = ?", [$tenantId]);
// Add tenant context to request for easy access in controllers
$request->attributes->set('current_tenant_id', $tenantId);
return $next($request);
}
/**
* Extract tenant ID from request
*/
private function getTenantIdFromRequest(Request $request): ?int
{
// 1. Route parameter (e.g., /api/v1/tenant/123/events)
if ($request->route('tenant')) {
return (int) $request->route('tenant');
}
// 2. Query parameter (e.g., ?tenant_id=123)
if ($request->query('tenant_id')) {
return (int) $request->query('tenant_id');
}
// 3. Header (X-Tenant-ID)
if ($request->header('X-Tenant-ID')) {
return (int) $request->header('X-Tenant-ID');
}
// 4. For tenant-specific resources, use token tenant_id
return null;
}
}

View File

@@ -0,0 +1,162 @@
<?php
namespace App\Http\Middleware;
use App\Models\TenantToken;
use Closure;
use Firebase\JWT\Exceptions\TokenExpiredException;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Validation\ValidationException;
class TenantTokenGuard
{
/**
* Handle an incoming request.
*/
public function handle(Request $request, Closure $next, ...$scopes)
{
$token = $this->getTokenFromRequest($request);
if (!$token) {
return response()->json(['error' => 'Token not provided'], 401);
}
try {
$decoded = $this->decodeToken($token);
} catch (\Exception $e) {
return response()->json(['error' => 'Invalid token'], 401);
}
// Check token blacklist
if ($this->isTokenBlacklisted($decoded)) {
return response()->json(['error' => 'Token has been revoked'], 401);
}
// Validate scopes if specified
if (!empty($scopes) && !$this->hasScopes($decoded, $scopes)) {
return response()->json(['error' => 'Insufficient scopes'], 403);
}
// Check expiration
if ($decoded['exp'] < time()) {
// Add to blacklist on expiry
$this->blacklistToken($decoded);
return response()->json(['error' => 'Token expired'], 401);
}
// Set tenant ID on request
$request->merge(['tenant_id' => $decoded['sub']]);
$request->attributes->set('decoded_token', $decoded);
return $next($request);
}
/**
* Get token from request (Bearer or header)
*/
private function getTokenFromRequest(Request $request): ?string
{
$header = $request->header('Authorization');
if (str_starts_with($header, 'Bearer ')) {
return substr($header, 7);
}
if ($request->header('X-API-Token')) {
return $request->header('X-API-Token');
}
return null;
}
/**
* Decode JWT token
*/
private function decodeToken(string $token): array
{
$publicKey = file_get_contents(storage_path('app/public.key'));
if (!$publicKey) {
throw new \Exception('JWT public key not found');
}
$decoded = JWT::decode($token, new Key($publicKey, 'RS256'));
return (array) $decoded;
}
/**
* Check if token is blacklisted
*/
private function isTokenBlacklisted(array $decoded): bool
{
$jti = isset($decoded['jti']) ? $decoded['jti'] : md5($decoded['sub'] . $decoded['iat']);
$cacheKey = "blacklisted_token:{$jti}";
// Check cache first (faster)
if (Cache::has($cacheKey)) {
return true;
}
// Check DB blacklist
$dbJti = $decoded['jti'] ?? null;
$blacklisted = TenantToken::where('jti', $dbJti)
->orWhere('token_hash', md5($decoded['sub'] . $decoded['iat']))
->where('expires_at', '>', now())
->exists();
if ($blacklisted) {
Cache::put($cacheKey, true, now()->addMinutes(5));
return true;
}
return false;
}
/**
* Add token to blacklist
*/
private function blacklistToken(array $decoded): void
{
$jti = $decoded['jti'] ?? md5($decoded['sub'] . $decoded['iat']);
$cacheKey = "blacklisted_token:{$jti}";
// Cache for immediate effect
Cache::put($cacheKey, true, $decoded['exp'] - time());
// Store in DB for persistence
TenantToken::updateOrCreate(
[
'jti' => $jti,
'tenant_id' => $decoded['sub'],
],
[
'token_hash' => md5(json_encode($decoded)),
'ip_address' => request()->ip(),
'user_agent' => request()->userAgent(),
'expires_at' => now()->addHours(24), // Keep for 24h after expiry
]
);
}
/**
* Check if token has required scopes
*/
private function hasScopes(array $decoded, array $requiredScopes): bool
{
$tokenScopes = $decoded['scopes'] ?? [];
if (!is_array($tokenScopes)) {
$tokenScopes = explode(' ', $tokenScopes);
}
foreach ($requiredScopes as $scope) {
if (!in_array($scope, $tokenScopes)) {
return false;
}
}
return true;
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace App\Http\Requests\Tenant;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class EventStoreRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true; // Authorization handled by middleware
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
$tenantId = request()->attributes->get('tenant_id');
return [
'name' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'event_date' => ['required', 'date', 'after_or_equal:today'],
'location' => ['nullable', 'string', 'max:255'],
'event_type_id' => ['required', 'exists:event_types,id'],
'max_participants' => ['nullable', 'integer', 'min:1', 'max:10000'],
'public_url' => ['nullable', 'url', 'max:500'],
'custom_domain' => ['nullable', 'string', 'max:255'],
'theme_color' => ['nullable', 'string', 'regex:/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/'],
'logo_image' => ['nullable', 'image', 'max:2048'], // 2MB
'cover_image' => ['nullable', 'image', 'max:5120'], // 5MB
'password_protected' => ['nullable', 'boolean'],
'password' => ['required_if:password_protected,true', 'string', 'min:6', 'confirmed'],
'status' => ['nullable', Rule::in(['draft', 'published', 'archived'])],
'features' => ['nullable', 'array'],
'features.*' => ['string'],
];
}
/**
* Get custom validation messages.
*/
public function messages(): array
{
return [
'event_date.after_or_equal' => 'Das Event-Datum darf nicht in der Vergangenheit liegen.',
'password.confirmed' => 'Die Passwortbestätigung stimmt nicht überein.',
'logo_image.image' => 'Das Logo muss ein Bild sein.',
'cover_image.image' => 'Das Cover-Bild muss ein Bild sein.',
'theme_color.regex' => 'Die Farbe muss im Hex-Format angegeben werden (z.B. #FF0000).',
];
}
/**
* Prepare the data for validation.
*/
protected function prepareForValidation()
{
$this->merge([
'password_protected' => $this->boolean('password_protected'),
]);
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Http\Requests\Tenant;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class PhotoStoreRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true; // Authorization handled by middleware
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array|string>
*/
public function rules(): array
{
return [
'photo' => [
'required',
'image',
'mimes:jpeg,png,webp',
'max:10240', // 10MB
],
'caption' => ['nullable', 'string', 'max:500'],
'alt_text' => ['nullable', 'string', 'max:255'],
'tags' => ['nullable', 'array', 'max:10'],
'tags.*' => ['string', 'max:50'],
];
}
/**
* Get custom validation messages.
*/
public function messages(): array
{
return [
'photo.required' => 'Ein Foto muss hochgeladen werden.',
'photo.image' => 'Die Datei muss ein Bild sein.',
'photo.mimes' => 'Nur JPEG, PNG und WebP Formate sind erlaubt.',
'photo.max' => 'Das Foto darf maximal 10MB groß sein.',
'caption.max' => 'Die Bildunterschrift darf maximal 500 Zeichen haben.',
];
}
/**
* Prepare the data for validation.
*/
protected function prepareForValidation()
{
$this->merge([
'tags' => $this->tags ? explode(',', $this->tags) : [],
]);
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace App\Http\Requests\Tenant;
use Illuminate\Foundation\Http\FormRequest;
class SettingsStoreRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array|string>
*/
public function rules(): array
{
return [
'settings' => ['required', 'array'],
'settings.branding' => ['sometimes', 'array'],
'settings.branding.logo_url' => ['nullable', 'url', 'max:500'],
'settings.branding.primary_color' => ['nullable', 'regex:/^#[0-9A-Fa-f]{6}$/'],
'settings.branding.secondary_color' => ['nullable', 'regex:/^#[0-9A-Fa-f]{6}$/'],
'settings.branding.font_family' => ['nullable', 'string', 'max:100'],
'settings.features' => ['sometimes', 'array'],
'settings.features.photo_likes_enabled' => ['nullable', 'boolean'],
'settings.features.event_checklist' => ['nullable', 'boolean'],
'settings.features.custom_domain' => ['nullable', 'boolean'],
'settings.features.advanced_analytics' => ['nullable', 'boolean'],
'settings.custom_domain' => ['nullable', 'string', 'max:255', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9-]{1,61}[a-zA-Z0-9]\.[a-zA-Z]{2,}$/'],
'settings.contact_email' => ['nullable', 'email', 'max:255'],
'settings.event_default_type' => ['nullable', 'string', 'max:50'],
];
}
/**
* Get custom messages for validator errors.
*/
public function messages(): array
{
return [
'settings.required' => 'Settings-Daten sind erforderlich.',
'settings.branding.logo_url.url' => 'Die Logo-URL muss eine gültige URL sein.',
'settings.branding.primary_color.regex' => 'Die Primärfarbe muss ein gültiges Hex-Format (#RRGGBB) haben.',
'settings.branding.secondary_color.regex' => 'Die Sekundärfarbe muss ein gültiges Hex-Format (#RRGGBB) haben.',
'settings.custom_domain.regex' => 'Das Custom Domain muss ein gültiges Domain-Format haben.',
'settings.contact_email.email' => 'Die Kontakt-E-Mail muss eine gültige E-Mail-Adresse sein.',
];
}
/**
* Prepare the data for validation.
*/
protected function prepareForValidation()
{
$this->merge([
'settings' => $this->input('settings', []),
]);
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Http\Requests\Tenant;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class TaskStoreRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array|string>
*/
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'collection_id' => ['nullable', 'exists:task_collections,id', function ($attribute, $value, $fail) {
$tenantId = request()->tenant?->id;
if ($tenantId && !\App\Models\TaskCollection::where('id', $value)->where('tenant_id', $tenantId)->exists()) {
$fail('Die TaskCollection gehört nicht zu diesem Tenant.');
}
}],
'priority' => ['nullable', Rule::in(['low', 'medium', 'high', 'urgent'])],
'due_date' => ['nullable', 'date', 'after:now'],
'is_completed' => ['nullable', 'boolean'],
'assigned_to' => ['nullable', 'exists:users,id', function ($attribute, $value, $fail) {
$tenantId = request()->tenant?->id;
if ($tenantId && !\App\Models\User::where('id', $value)->where('tenant_id', $tenantId)->exists()) {
$fail('Der Benutzer gehört nicht zu diesem Tenant.');
}
}],
];
}
/**
* Get custom messages for validator errors.
*/
public function messages(): array
{
return [
'title.required' => 'Der Task-Titel ist erforderlich.',
'title.max' => 'Der Task-Titel darf maximal 255 Zeichen haben.',
'collection_id.exists' => 'Die ausgewählte TaskCollection existiert nicht.',
'priority.in' => 'Die Priorität muss low, medium, high oder urgent sein.',
'due_date.after' => 'Das Fälligkeitsdatum muss in der Zukunft liegen.',
'assigned_to.exists' => 'Der zugewiesene Benutzer existiert nicht.',
];
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace App\Http\Requests\Tenant;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class TaskUpdateRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array|string>
*/
public function rules(): array
{
return [
'title' => ['sometimes', 'required', 'string', 'max:255'],
'description' => ['sometimes', 'nullable', 'string'],
'collection_id' => ['sometimes', 'nullable', 'exists:task_collections,id', function ($attribute, $value, $fail) {
$tenantId = request()->tenant?->id;
if ($tenantId && !\App\Models\TaskCollection::where('id', $value)->where('tenant_id', $tenantId)->exists()) {
$fail('Die TaskCollection gehört nicht zu diesem Tenant.');
}
}],
'priority' => ['sometimes', 'nullable', Rule::in(['low', 'medium', 'high', 'urgent'])],
'due_date' => ['sometimes', 'nullable', 'date'],
'is_completed' => ['sometimes', 'boolean'],
'assigned_to' => ['sometimes', 'nullable', 'exists:users,id', function ($attribute, $value, $fail) {
$tenantId = request()->tenant?->id;
if ($tenantId && !\App\Models\User::where('id', $value)->where('tenant_id', $tenantId)->exists()) {
$fail('Der Benutzer gehört nicht zu diesem Tenant.');
}
}],
];
}
/**
* Get custom messages for validator errors.
*/
public function messages(): array
{
return [
'title.required' => 'Der Task-Titel ist erforderlich.',
'title.max' => 'Der Task-Titel darf maximal 255 Zeichen haben.',
'collection_id.exists' => 'Die ausgewählte TaskCollection existiert nicht.',
'priority.in' => 'Die Priorität muss low, medium, high oder urgent sein.',
'assigned_to.exists' => 'Der zugewiesene Benutzer existiert nicht.',
];
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Http\Resources\Tenant;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class CreditLedgerResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'delta' => $this->delta,
'reason' => $this->reason,
'note' => $this->note,
'related_purchase_id' => $this->related_purchase_id,
'created_at' => $this->created_at->toISOString(),
];
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Http\Resources\Tenant;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class EventPurchaseResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'events_purchased' => $this->events_purchased,
'amount' => $this->amount,
'currency' => $this->currency,
'provider' => $this->provider,
'status' => $this->status,
'external_receipt_id' => $this->external_receipt_id,
'purchased_at' => $this->purchased_at ? $this->purchased_at->toISOString() : null,
'created_at' => $this->created_at->toISOString(),
];
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Http\Resources\Tenant;
use App\Http\Resources\Tenant\EventTypeResource;
use App\Http\Resources\Tenant\PhotoResource;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class EventResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
$tenantId = $request->attributes->get('tenant_id');
// Hide sensitive data for other tenants
$showSensitive = $this->tenant_id === $tenantId;
return [
'id' => $this->id,
'name' => $this->name,
'slug' => $this->slug,
'description' => $this->description,
'event_date' => $this->event_date ? $this->event_date->toISOString() : null,
'location' => $this->location,
'max_participants' => $this->max_participants,
'current_participants' => $showSensitive ? $this->photos_count : null,
'public_url' => $this->public_url,
'custom_domain' => $showSensitive ? $this->custom_domain : null,
'theme_color' => $this->theme_color,
'status' => $showSensitive ? $this->status : 'published',
'password_protected' => $this->password_protected,
'features' => $this->features,
'event_type' => new EventTypeResource($this->whenLoaded('eventType')),
'photos' => PhotoResource::collection($this->whenLoaded('photos')),
'tasks' => $showSensitive ? $this->whenLoaded('tasks') : [],
'tenant' => $showSensitive ? [
'id' => $this->tenant->id,
'name' => $this->tenant->name,
'event_credits_balance' => $this->tenant->event_credits_balance,
] : null,
'created_at' => $this->created_at->toISOString(),
'updated_at' => $this->updated_at->toISOString(),
'photo_count' => $this->photos_count,
'like_count' => $this->photos->sum('likes_count'),
'is_public' => $this->status === 'published' && !$this->password_protected,
'public_share_url' => $showSensitive ? route('api.v1.events.show', ['slug' => $this->slug]) : null,
'qr_code_url' => $showSensitive ? route('api.v1.events.qr', ['event' => $this->id]) : null,
];
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Http\Resources\Tenant;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class EventTypeResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'description' => $this->description,
'icon' => $this->icon,
'color' => $this->color,
'is_active' => $this->is_active,
'created_at' => $this->created_at->toISOString(),
'updated_at' => $this->updated_at->toISOString(),
];
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Http\Resources\Tenant;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class PhotoResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
$tenantId = $request->attributes->get('tenant_id');
$showSensitive = $this->event->tenant_id === $tenantId;
return [
'id' => $this->id,
'filename' => $this->filename,
'original_name' => $this->original_name,
'mime_type' => $this->mime_type,
'size' => $this->size,
'url' => $showSensitive ? $this->getFullUrl() : $this->getThumbnailUrl(),
'thumbnail_url' => $this->getThumbnailUrl(),
'width' => $this->width,
'height' => $this->height,
'status' => $showSensitive ? $this->status : 'approved',
'moderation_notes' => $showSensitive ? $this->moderation_notes : null,
'likes_count' => $this->likes_count,
'is_liked' => $showSensitive ? $this->isLikedByTenant($tenantId) : false,
'uploaded_at' => $this->created_at->toISOString(),
'event' => [
'id' => $this->event->id,
'name' => $this->event->name,
'slug' => $this->event->slug,
],
];
}
/**
* Get full image URL
*/
private function getFullUrl(): string
{
return url("storage/events/{$this->event->slug}/photos/{$this->filename}");
}
/**
* Get thumbnail URL
*/
private function getThumbnailUrl(): string
{
return url("storage/events/{$this->event->slug}/thumbnails/{$this->filename}");
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Http\Resources\Tenant;
use App\Http\Resources\Tenant\EventResource;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class TaskResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'description' => $this->description,
'priority' => $this->priority,
'due_date' => $this->due_date?->toISOString(),
'is_completed' => $this->is_completed,
'collection_id' => $this->collection_id,
'assigned_events_count' => $this->assignedEvents()->count(),
// TaskCollectionResource wird später implementiert
// 'collection' => $this->whenLoaded('taskCollection', function () {
// return new TaskCollectionResource($this->taskCollection);
// }),
'assigned_events' => $this->whenLoaded('assignedEvents', function () {
return EventResource::collection($this->assignedEvents);
}),
// UserResource wird später implementiert
// 'assigned_to' => $this->whenLoaded('assignedTo', function () {
// return new UserResource($this->assignedTo);
// }),
'created_at' => $this->created_at->toISOString(),
'updated_at' => $this->updated_at->toISOString(),
];
}
}

View File

@@ -0,0 +1,99 @@
<?php
namespace App\Jobs;
use App\Models\EventPurchase;
use App\Models\Tenant;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class ValidateStripeWebhookJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $payload;
public $sig;
public $tries = 3;
public $backoff = [60, 300, 600]; // Retry delays
public function __construct($payload, $sig)
{
$this->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']]);
}
}
}

View File

@@ -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'
);
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class EventCreditsLedger extends Model
{
use HasFactory;
protected $table = 'event_credits_ledger';
protected $guarded = [];
protected $casts = [
'created_at' => 'datetime',
'delta' => 'integer',
];
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
public function relatedPurchase(): BelongsTo
{
return $this->belongsTo(EventPurchase::class, 'related_purchase_id');
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class EventPurchase extends Model
{
use HasFactory;
protected $table = 'event_purchases';
protected $guarded = [];
protected $casts = [
'purchased_at' => 'datetime',
'amount' => 'decimal:2',
];
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class OAuthClient extends Model
{
protected $table = 'oauth_clients';
protected $guarded = [];
protected $fillable = [
'id',
'client_id',
'client_secret',
'redirect_uris',
'scopes',
];
protected $casts = [
'scopes' => 'array',
'redirect_uris' => 'array',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
}

45
app/Models/OAuthCode.php Normal file
View File

@@ -0,0 +1,45 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class OAuthCode extends Model
{
protected $table = 'oauth_codes';
protected $guarded = [];
protected $fillable = [
'id',
'client_id',
'user_id',
'code',
'code_challenge',
'state',
'redirect_uri',
'scope',
'expires_at',
];
protected $casts = [
'expires_at' => '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();
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PurchaseHistory extends Model
{
protected $table = 'purchase_history';
protected $guarded = [];
protected $fillable = [
'id',
'tenant_id',
'package_id',
'credits_added',
'price',
'currency',
'platform',
'transaction_id',
'purchased_at',
];
protected $casts = [
'credits_added' => 'integer',
'price' => 'decimal:2',
'purchased_at' => 'datetime',
'created_at' => 'datetime',
];
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class RefreshToken extends Model
{
protected $table = 'refresh_tokens';
protected $guarded = [];
protected $fillable = [
'id',
'tenant_id',
'token',
'access_token',
'expires_at',
'scope',
'ip_address',
'user_agent',
'revoked_at',
];
protected $casts = [
'expires_at' => '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);
}
}

View File

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

View File

@@ -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();
);
}
/**

View File

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

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class TenantToken extends Model
{
protected $table = 'tenant_tokens';
protected $guarded = [];
protected $fillable = [
'id',
'tenant_id',
'jti',
'token_type',
'expires_at',
'revoked_at',
];
protected $casts = [
'expires_at' => '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();
}
}

View File

@@ -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.

View File

@@ -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) {
//

View File

@@ -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",

188
composer.lock generated
View File

@@ -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",

View File

@@ -40,6 +40,10 @@ return [
'driver' => 'session',
'provider' => 'users',
],
'api' => [
'driver' => 'sanctum',
'provider' => 'users',
],
],
/*

84
config/sanctum.php Normal file
View File

@@ -0,0 +1,84 @@
<?php
use Laravel\Sanctum\Sanctum;
return [
/*
|--------------------------------------------------------------------------
| Stateful Domains
|--------------------------------------------------------------------------
|
| Requests from the following domains / hosts will receive stateful API
| authentication cookies. Typically, these should include your local
| and production domains which access your API via a frontend SPA.
|
*/
'stateful' => 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,
],
];

View File

@@ -35,4 +35,9 @@ return [
],
],
'stripe' => [
'key' => env('STRIPE_KEY'),
'secret' => env('STRIPE_SECRET'),
'webhook' => env('STRIPE_WEBHOOK_SECRET'),
],
];

View File

@@ -0,0 +1,63 @@
<?php
namespace Database\Factories;
use App\Models\Event;
use App\Models\Tenant;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
class EventFactory extends Factory
{
protected $model = Event::class;
public function definition(): array
{
$name = $this->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'),
]);
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace Database\Factories;
use App\Models\TaskCollection;
use App\Models\Tenant;
use Illuminate\Database\Eloquent\Factories\Factory;
class TaskCollectionFactory extends Factory
{
protected $model = TaskCollection::class;
public function definition(): array
{
$categories = ['Allgemein', 'Vorbereitung', 'Event-Tag', 'Aufräumen', 'Follow-up'];
$colors = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6'];
return [
'tenant_id' => 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,
]);
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace Database\Factories;
use App\Models\Task;
use App\Models\TaskCollection;
use App\Models\Tenant;
use Illuminate\Database\Eloquent\Factories\Factory;
class TaskFactory extends Factory
{
protected $model = Task::class;
public function definition(): array
{
return [
'tenant_id' => 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);
});
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace Database\Factories;
use App\Models\Tenant;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
class TenantFactory extends Factory
{
protected $model = Tenant::class;
public function definition(): array
{
$name = $this->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,
]);
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('event_task', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('oauth_clients', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('refresh_tokens', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('tenant_tokens', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('oauth_codes', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('purchase_history', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('tenants', function (Blueprint $table) {
$table->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'
]);
});
}
};

View File

@@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
// Add tenant_id to tasks table
Schema::table('tasks', function (Blueprint $table) {
if (!Schema::hasColumn('tasks', 'tenant_id')) {
$table->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');
});
}
};

View File

@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('tenants', function (Blueprint $table) {
$table->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'
]);
});
}
};

View File

@@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up()
{
Schema::table('tenants', function (Blueprint $table) {
$table->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');
});
}
};

View File

@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('event_credits_ledger', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('event_purchases', function (Blueprint $table) {
$table->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');
}
};

View File

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

View File

@@ -1,73 +0,0 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use App\Models\{Event, Emotion, Task, Photo};
class DemoAchievementsSeeder extends Seeder
{
public function run(): void
{
$event = Event::where('slug', 'demo-wedding-2025')->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(),
]);
}
}
}

View File

@@ -0,0 +1,97 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Str;
use App\Models\{Event, Task, Emotion, Photo, PhotoLike, Tenant};
use Illuminate\Support\Facades\File;
use Carbon\Carbon;
class DemoPhotosSeeder extends Seeder
{
public function run(): void
{
// Get demo event and tenant
$demoEvent = Event::where('slug', 'demo-wedding-2025')->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");
}
}

View File

@@ -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],

View File

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

View File

@@ -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'],

View File

@@ -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.

View File

@@ -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.

38
manifest.json Normal file
View File

@@ -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
}
}

View File

@@ -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');
});
});
});

View File

@@ -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 = [

View File

@@ -0,0 +1,23 @@
<?php
namespace Tests\Feature\Tenant;
use App\Models\Tenant;
use Closure;
use Illuminate\Http\Request;
class MockTenantMiddleware
{
public function handle(Request $request, Closure $next)
{
// Skip auth for tests and set mock tenant from app instance
$tenant = app('tenant');
if ($tenant) {
$request->attributes->set('tenant', $tenant);
$request->attributes->set('tenant_id', $tenant->id);
$request->merge(['tenant_id' => $tenant->id]);
}
return $next($request);
}
}

View File

@@ -0,0 +1,190 @@
<?php
namespace Tests\Feature\Tenant;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class SettingsApiTest extends TenantTestCase
{
use RefreshDatabase;
protected Tenant $tenant;
protected User $tenantUser;
protected string $token;
protected function setUp(): void
{
parent::setUp();
$this->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
}
}

View File

@@ -0,0 +1,306 @@
<?php
namespace Tests\Feature\Tenant;
use App\Models\Event;
use App\Models\Task;
use App\Models\TaskCollection;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class TaskApiTest extends TenantTestCase
{
use RefreshDatabase;
protected Tenant $tenant;
protected User $tenantUser;
protected string $token;
protected function setUp(): void
{
parent::setUp();
$this->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');
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace Tests\Feature\Tenant;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use Tests\TestCase;
abstract class TenantTestCase extends TestCase
{
use RefreshDatabase;
protected Tenant $tenant;
protected User $tenantUser;
protected string $token;
protected function setUp(): void
{
parent::setUp();
$this->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);
}
}

View File

@@ -0,0 +1,132 @@
<?php
namespace Tests\Unit;
use App\Models\Event;
use App\Models\Photo;
use App\Models\PurchaseHistory;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class TenantModelTest extends TestCase
{
use RefreshDatabase;
/** @test */
public function tenant_has_many_events()
{
$tenant = Tenant::factory()->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']);
}
}