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

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

View File

@@ -0,0 +1,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.