Add superadmin moderation queues
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-01-01 18:52:32 +01:00
parent 4fbd0815a4
commit 117250879b
22 changed files with 1324 additions and 5 deletions

View File

@@ -57,7 +57,7 @@
{"id":"fotospiel-app-g5o","title":"SEC-MS-04 Storage health widget in Super Admin","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:53:15.088501536+01:00","created_by":"soeren","updated_at":"2026-01-01T15:53:20.739996548+01:00","closed_at":"2026-01-01T15:53:20.739996548+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-g74","title":"Paddle migration: automated tests for checkout/webhooks/sync","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:58:34.795423009+01:00","created_by":"soeren","updated_at":"2026-01-01T15:58:40.467997776+01:00","closed_at":"2026-01-01T15:58:40.467997776+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-gsv","title":"Localized SEO: validate hreflang via Search Console/Lighthouse","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:02:36.4821072+01:00","created_by":"soeren","updated_at":"2026-01-01T16:02:36.4821072+01:00"}
{"id":"fotospiel-app-hbt","title":"Moderation queue for guest content","description":"Queue for flagged guest content (photos, feedback). Bulk actions to hide/delete/resolve with audit.","status":"open","priority":1,"issue_type":"feature","created_at":"2026-01-01T14:18:37.777772819+01:00","updated_at":"2026-01-01T14:18:37.777772819+01:00"}
{"id":"fotospiel-app-hbt","title":"Moderation queue for guest content","description":"Queue for flagged guest content (photos, feedback). Bulk actions to hide/delete/resolve with audit.","notes":"Land the plane: tests run (FilamentPanelNavigationTest, PhotoModerationQueueTest, TenantFeedbackModerationQueueTest, TenantLifecycle*), migrations added for photo + feedback moderation, no follow-up blockers.","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-01-01T14:18:37.777772819+01:00","updated_at":"2026-01-01T18:50:57.274743566+01:00","closed_at":"2026-01-01T18:46:09.677538603+01:00"}
{"id":"fotospiel-app-ihd","title":"Superadmin control surface spec and access matrix","description":"Define the minimal superadmin control surface, permissions, and mapping to tenant/guest responsibilities. Document scope and non-goals.","notes":"Spec v1: Superadmin control surface\\n\\nGoals\\n- Practical controls over tenant admin + guest experience (safety, limits, visibility).\\n- Fast response to abuse/outages without deploys.\\n- GDPR-safe: no new PII logging; audit log stores action metadata only.\\n\\nNon-goals\\n- New tracking beyond anonymous guest session_id.\\n- Deep analytics beyond operational KPIs.\\n\\nAccess matrix (high-level)\\n- Guest: upload/like/join per event only, no admin privileges.\\n- Tenant Admin: manage their events/photos/tasks; no cross-tenant access.\\n- Superadmin: global visibility + override controls + audit trail.\\n\\nProposed control areas\\nDaily Ops\\n- Tenant Lifecycle: status (active/suspended/grace), limits (uploads/storage/events), manual overrides.\\n- Moderation Queue: flagged photos/feedback; hide/delete/resolve/bulk actions.\\n- Support: Tenant feedback triage view.\\n\\nWeekly Ops\\n- Guest Policy: feature toggles + rate limits + retention defaults.\\n- Event Access: join token TTL, max uses, invalidate/regenerate.\\n- Commercial: packages/addons/coupons/tenant packages.\\n\\nRare/Admin\\n- Ops Health: queues, failed jobs, storage thresholds.\\n- Compliance: data export requests + retention overrides.\\n- Audit Log: superadmin actions (no PII payloads).\\n- Integrations health: Paddle/RevenueCat/webhooks status.\\n\\nData model considerations\\n- Existing JSON fields: tenants.settings/features; events.settings; tenant_feedback.metadata; photos.security_meta.\\n- Prefer new tables for auditability: moderation_items, super_admin_audit_logs, data_export_requests, retention_overrides, guest_policy_settings.\\n- Tenant lifecycle limits can be a new table (tenant_overrides) or fields on tenants (status, grace_until, limits JSON).\\n\\nSuccess criteria\\n- Each resource renders in superadmin panel without errors.\\n- Actions are logged (audit log).\\n- Policies enforce tenant isolation + superadmin override.","status":"in_progress","priority":2,"issue_type":"task","created_at":"2026-01-01T14:18:10.789147344+01:00","updated_at":"2026-01-01T14:32:31.455392845+01:00"}
{"id":"fotospiel-app-iyc","title":"Superadmin audit log for admin actions","description":"Audit trail for superadmin actions without PII payloads.","status":"open","priority":2,"issue_type":"feature","created_at":"2026-01-01T14:20:19.043695952+01:00","updated_at":"2026-01-01T14:20:19.043695952+01:00"}
{"id":"fotospiel-app-iyh","title":"Security review follow-ups: signed URL TTLs, guest asset throttles, CORS allowlist, logging hygiene","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:42.642109576+01:00","created_by":"soeren","updated_at":"2026-01-01T16:05:42.642109576+01:00"}

View File

@@ -1 +1 @@
--stealth-d39
fotospiel-app-hbt

View File

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

View File

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

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Filament\Clusters\DailyOps\Resources\Photos\Pages;
use App\Filament\Clusters\DailyOps\Resources\Photos\PhotoResource;
use Filament\Resources\Pages\ListRecords;
class ListPhotos extends ListRecords
{
protected static string $resource = PhotoResource::class;
protected function getHeaderActions(): array
{
return [];
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Filament\Clusters\DailyOps\Resources\Photos\Pages;
use App\Filament\Clusters\DailyOps\Resources\Photos\PhotoResource;
use Filament\Resources\Pages\ViewRecord;
class ViewPhoto extends ViewRecord
{
protected static string $resource = PhotoResource::class;
protected function getHeaderActions(): array
{
return [];
}
}

View File

@@ -0,0 +1,84 @@
<?php
namespace App\Filament\Clusters\DailyOps\Resources\Photos;
use App\Filament\Clusters\DailyOps\DailyOpsCluster;
use App\Filament\Clusters\DailyOps\Resources\Photos\Pages\ListPhotos;
use App\Filament\Clusters\DailyOps\Resources\Photos\Pages\ViewPhoto;
use App\Filament\Clusters\DailyOps\Resources\Photos\Schemas\PhotoForm;
use App\Filament\Clusters\DailyOps\Resources\Photos\Schemas\PhotoInfolist;
use App\Filament\Clusters\DailyOps\Resources\Photos\Tables\PhotosTable;
use App\Models\Photo;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use UnitEnum;
class PhotoResource extends Resource
{
protected static ?string $model = Photo::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedShieldCheck;
protected static ?string $cluster = DailyOpsCluster::class;
protected static ?string $slug = 'moderation-queue';
protected static ?string $recordTitleAttribute = 'id';
protected static ?int $navigationSort = 20;
public static function canCreate(): bool
{
return false;
}
public static function form(Schema $schema): Schema
{
return PhotoForm::configure($schema);
}
public static function infolist(Schema $schema): Schema
{
return PhotoInfolist::configure($schema);
}
public static function table(Table $table): Table
{
return PhotosTable::configure($table);
}
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()
->with(['event.tenant', 'moderator']);
}
public static function getNavigationLabel(): string
{
return __('admin.moderation.navigation.label');
}
public static function getNavigationGroup(): UnitEnum|string|null
{
return __('admin.nav.curation');
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => ListPhotos::route('/'),
'view' => ViewPhoto::route('/{record}'),
];
}
}

View File

@@ -0,0 +1,74 @@
<?php
namespace App\Filament\Clusters\DailyOps\Resources\Photos\Schemas;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Schema;
class PhotoForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
Select::make('event_id')
->relationship('event', 'name')
->required(),
Select::make('emotion_id')
->relationship('emotion', 'name'),
Select::make('task_id')
->relationship('task', 'title'),
TextInput::make('guest_name')
->required(),
TextInput::make('file_path')
->required(),
TextInput::make('thumbnail_path')
->required(),
TextInput::make('likes_count')
->required()
->numeric()
->default(0),
Toggle::make('is_featured')
->required(),
Textarea::make('metadata')
->columnSpanFull(),
TextInput::make('tenant_id')
->numeric(),
Select::make('media_asset_id')
->relationship('mediaAsset', 'id'),
TextInput::make('security_scan_status')
->required()
->default('pending'),
Textarea::make('security_scan_message')
->columnSpanFull(),
DateTimePicker::make('security_scanned_at'),
Textarea::make('security_meta')
->columnSpanFull(),
TextInput::make('ingest_source')
->required()
->default('guest_pwa'),
TextInput::make('filename'),
TextInput::make('original_name'),
TextInput::make('mime_type'),
TextInput::make('size')
->numeric(),
TextInput::make('width')
->numeric(),
TextInput::make('height')
->numeric(),
TextInput::make('status')
->required()
->default('pending'),
TextInput::make('uploader_id')
->numeric(),
TextInput::make('ip_address'),
Textarea::make('user_agent')
->columnSpanFull(),
TextInput::make('created_by_device_id'),
]);
}
}

View File

@@ -0,0 +1,109 @@
<?php
namespace App\Filament\Clusters\DailyOps\Resources\Photos\Schemas;
use App\Models\Photo;
use Filament\Infolists\Components\ImageEntry;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
class PhotoInfolist
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
Section::make(__('admin.moderation.sections.photo'))
->columns(3)
->schema([
ImageEntry::make('thumbnail_path')
->label(__('admin.moderation.fields.photo'))
->disk('public')
->visibility('public')
->getStateUsing(fn (Photo $record) => $record->thumbnail_path ?: $record->file_path)
->columnSpanFull(),
TextEntry::make('event.name')
->label(__('admin.moderation.fields.event'))
->placeholder('—'),
TextEntry::make('event.tenant.name')
->label(__('admin.moderation.fields.tenant'))
->placeholder('—'),
TextEntry::make('guest_name')
->label(__('admin.moderation.fields.uploader'))
->placeholder('—'),
TextEntry::make('created_at')
->label(__('admin.moderation.fields.uploaded_at'))
->since()
->placeholder('—'),
TextEntry::make('ingest_source')
->label(__('admin.moderation.fields.ingest_source'))
->formatStateUsing(fn (?string $state) => match ($state) {
Photo::SOURCE_GUEST_PWA => __('admin.moderation.ingest_sources.guest_pwa'),
Photo::SOURCE_TENANT_ADMIN => __('admin.moderation.ingest_sources.tenant_admin'),
Photo::SOURCE_PHOTOBOOTH => __('admin.moderation.ingest_sources.photobooth'),
Photo::SOURCE_SPARKBOOTH => __('admin.moderation.ingest_sources.sparkbooth'),
Photo::SOURCE_UNKNOWN => __('admin.moderation.ingest_sources.unknown'),
default => '—',
}),
]),
Section::make(__('admin.moderation.sections.moderation'))
->columns(2)
->schema([
TextEntry::make('status')
->label(__('admin.moderation.fields.status'))
->badge()
->color(fn (?string $state) => match ($state) {
'approved' => 'success',
'rejected' => 'danger',
'hidden' => 'gray',
default => 'warning',
})
->formatStateUsing(fn (?string $state) => match ($state) {
'pending' => __('admin.moderation.status.pending'),
'approved' => __('admin.moderation.status.approved'),
'rejected' => __('admin.moderation.status.rejected'),
'hidden' => __('admin.moderation.status.hidden'),
default => '—',
}),
TextEntry::make('moderator.name')
->label(__('admin.moderation.fields.moderated_by'))
->placeholder('—'),
TextEntry::make('moderated_at')
->label(__('admin.moderation.fields.moderated_at'))
->dateTime()
->placeholder('—'),
TextEntry::make('moderation_notes')
->label(__('admin.moderation.fields.moderation_notes'))
->placeholder('—')
->columnSpanFull(),
TextEntry::make('security_scan_status')
->label(__('admin.moderation.fields.security_scan_status'))
->badge()
->color(fn (?string $state) => match ($state) {
'clean', 'skipped', 'stripped' => 'success',
'infected' => 'danger',
'error' => 'warning',
default => 'gray',
})
->formatStateUsing(fn (?string $state) => match ($state) {
'pending' => __('admin.moderation.security_scan.pending'),
'clean' => __('admin.moderation.security_scan.clean'),
'infected' => __('admin.moderation.security_scan.infected'),
'skipped' => __('admin.moderation.security_scan.skipped'),
'stripped' => __('admin.moderation.security_scan.stripped'),
'error' => __('admin.moderation.security_scan.error'),
default => '—',
}),
TextEntry::make('security_scan_message')
->label(__('admin.moderation.fields.security_scan_message'))
->placeholder('—')
->columnSpanFull(),
TextEntry::make('security_scanned_at')
->label(__('admin.moderation.fields.security_scanned_at'))
->dateTime()
->placeholder('—'),
]),
]);
}
}

View File

@@ -0,0 +1,261 @@
<?php
namespace App\Filament\Clusters\DailyOps\Resources\Photos\Tables;
use App\Models\Event;
use App\Models\Photo;
use App\Models\Tenant;
use Filament\Actions\Action;
use Filament\Actions\BulkAction;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\ViewAction;
use Filament\Facades\Filament;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Textarea;
use Filament\Support\Icons\Heroicon;
use Filament\Tables;
use Filament\Tables\Columns\ImageColumn;
use Filament\Tables\Filters\Filter;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
class PhotosTable
{
public static function configure(Table $table): Table
{
return $table
->defaultSort('created_at', 'desc')
->columns([
ImageColumn::make('thumbnail_path')
->label(__('admin.moderation.table.photo'))
->disk('public')
->visibility('public')
->circular()
->getStateUsing(fn (Photo $record) => $record->thumbnail_path ?: $record->file_path),
Tables\Columns\TextColumn::make('event.name')
->label(__('admin.moderation.table.event'))
->searchable()
->limit(30),
Tables\Columns\TextColumn::make('event.tenant.name')
->label(__('admin.moderation.table.tenant'))
->limit(30),
Tables\Columns\TextColumn::make('guest_name')
->label(__('admin.moderation.table.uploader'))
->searchable()
->limit(20),
Tables\Columns\TextColumn::make('status')
->label(__('admin.moderation.table.status'))
->badge()
->color(fn (?string $state) => match ($state) {
'approved' => 'success',
'rejected' => 'danger',
'hidden' => 'gray',
default => 'warning',
})
->formatStateUsing(fn (?string $state) => self::statusLabels()[$state] ?? '—')
->sortable(),
Tables\Columns\TextColumn::make('security_scan_status')
->label(__('admin.moderation.table.security_scan'))
->badge()
->color(fn (?string $state) => match ($state) {
'clean', 'skipped', 'stripped' => 'success',
'infected' => 'danger',
'error' => 'warning',
default => 'gray',
})
->formatStateUsing(fn (?string $state) => self::securityScanLabels()[$state] ?? '—')
->toggleable(),
Tables\Columns\TextColumn::make('ingest_source')
->label(__('admin.moderation.table.ingest_source'))
->badge()
->color('gray')
->formatStateUsing(fn (?string $state) => self::ingestSourceLabels()[$state] ?? '—')
->toggleable(),
Tables\Columns\TextColumn::make('created_at')
->label(__('admin.moderation.table.uploaded_at'))
->since()
->sortable(),
Tables\Columns\TextColumn::make('moderator.name')
->label(__('admin.moderation.table.moderated_by'))
->placeholder('—')
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('moderated_at')
->label(__('admin.moderation.table.moderated_at'))
->since()
->placeholder('—')
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
SelectFilter::make('status')
->label(__('admin.moderation.filters.status'))
->options(self::statusLabels())
->default('pending'),
SelectFilter::make('ingest_source')
->label(__('admin.moderation.filters.ingest_source'))
->options(self::ingestSourceLabels())
->default(Photo::SOURCE_GUEST_PWA),
SelectFilter::make('security_scan_status')
->label(__('admin.moderation.filters.security_scan_status'))
->options(self::securityScanLabels()),
SelectFilter::make('tenant_id')
->label(__('admin.common.tenant'))
->options(fn () => Tenant::query()->orderBy('name')->pluck('name', 'id')->toArray())
->searchable(),
SelectFilter::make('event_id')
->label(__('admin.common.event'))
->options(fn () => Event::query()->orderBy('name')->pluck('name', 'id')->toArray())
->searchable(),
Filter::make('created_at')
->label(__('admin.moderation.filters.uploaded_at'))
->form([
DatePicker::make('from')->label(__('admin.common.from')),
DatePicker::make('until')->label(__('admin.common.until')),
])
->query(function (Builder $query, array $data): Builder {
return $query
->when(
$data['from'] ?? null,
fn (Builder $query, string $date): Builder => $query->whereDate('created_at', '>=', $date)
)
->when(
$data['until'] ?? null,
fn (Builder $query, string $date): Builder => $query->whereDate('created_at', '<=', $date)
);
}),
])
->recordActions([
ViewAction::make(),
Action::make('approve')
->label(__('admin.moderation.actions.approve'))
->color('success')
->icon(Heroicon::OutlinedCheckCircle)
->visible(fn (Photo $record) => $record->status === 'pending')
->form([
self::moderationNotesField(false),
])
->requiresConfirmation()
->action(fn (Photo $record, array $data) => self::applyModeration($record, 'approved', $data['moderation_notes'] ?? null)),
Action::make('reject')
->label(__('admin.moderation.actions.reject'))
->color('danger')
->icon(Heroicon::OutlinedXCircle)
->visible(fn (Photo $record) => $record->status === 'pending')
->form([
self::moderationNotesField(true),
])
->requiresConfirmation()
->action(fn (Photo $record, array $data) => self::applyModeration($record, 'rejected', $data['moderation_notes'] ?? null)),
Action::make('hide')
->label(__('admin.moderation.actions.hide'))
->color('gray')
->icon(Heroicon::OutlinedEyeSlash)
->visible(fn (Photo $record) => $record->status !== 'hidden')
->form([
self::moderationNotesField(false),
])
->requiresConfirmation()
->action(fn (Photo $record, array $data) => self::applyModeration($record, 'hidden', $data['moderation_notes'] ?? null)),
])
->toolbarActions([
BulkActionGroup::make([
BulkAction::make('approve')
->label(__('admin.moderation.actions.approve_selected'))
->icon(Heroicon::OutlinedCheckCircle)
->color('success')
->form([
self::moderationNotesField(false),
])
->requiresConfirmation()
->action(fn (Collection $records, array $data) => self::applyModerationToRecords($records, 'approved', $data['moderation_notes'] ?? null)),
BulkAction::make('reject')
->label(__('admin.moderation.actions.reject_selected'))
->icon(Heroicon::OutlinedXCircle)
->color('danger')
->form([
self::moderationNotesField(true),
])
->requiresConfirmation()
->action(fn (Collection $records, array $data) => self::applyModerationToRecords($records, 'rejected', $data['moderation_notes'] ?? null)),
BulkAction::make('hide')
->label(__('admin.moderation.actions.hide_selected'))
->icon(Heroicon::OutlinedEyeSlash)
->color('gray')
->form([
self::moderationNotesField(false),
])
->requiresConfirmation()
->action(fn (Collection $records, array $data) => self::applyModerationToRecords($records, 'hidden', $data['moderation_notes'] ?? null)),
]),
]);
}
private static function moderationNotesField(bool $required): Textarea
{
return Textarea::make('moderation_notes')
->label(__('admin.moderation.fields.moderation_notes'))
->maxLength(1000)
->rows(3)
->required($required);
}
private static function applyModeration(Photo $record, string $status, ?string $notes): void
{
$record->update([
'status' => $status,
'moderation_notes' => $notes,
'moderated_at' => now(),
'moderated_by' => Filament::auth()->id(),
]);
}
private static function applyModerationToRecords(Collection $records, string $status, ?string $notes): int
{
$moderatedAt = now();
$moderatedBy = Filament::auth()->id();
return Photo::query()
->whereIn('id', $records->pluck('id'))
->where('status', 'pending')
->update([
'status' => $status,
'moderation_notes' => $notes,
'moderated_at' => $moderatedAt,
'moderated_by' => $moderatedBy,
]);
}
private static function statusLabels(): array
{
return [
'pending' => __('admin.moderation.status.pending'),
'approved' => __('admin.moderation.status.approved'),
'rejected' => __('admin.moderation.status.rejected'),
'hidden' => __('admin.moderation.status.hidden'),
];
}
private static function ingestSourceLabels(): array
{
return [
Photo::SOURCE_GUEST_PWA => __('admin.moderation.ingest_sources.guest_pwa'),
Photo::SOURCE_TENANT_ADMIN => __('admin.moderation.ingest_sources.tenant_admin'),
Photo::SOURCE_PHOTOBOOTH => __('admin.moderation.ingest_sources.photobooth'),
Photo::SOURCE_SPARKBOOTH => __('admin.moderation.ingest_sources.sparkbooth'),
Photo::SOURCE_UNKNOWN => __('admin.moderation.ingest_sources.unknown'),
];
}
private static function securityScanLabels(): array
{
return [
'pending' => __('admin.moderation.security_scan.pending'),
'clean' => __('admin.moderation.security_scan.clean'),
'infected' => __('admin.moderation.security_scan.infected'),
'skipped' => __('admin.moderation.security_scan.skipped'),
'stripped' => __('admin.moderation.security_scan.stripped'),
'error' => __('admin.moderation.security_scan.error'),
];
}
}

View File

@@ -14,6 +14,7 @@ use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use UnitEnum;
class TenantFeedbackResource extends Resource
@@ -26,7 +27,7 @@ class TenantFeedbackResource extends Resource
protected static UnitEnum|string|null $navigationGroup = null;
protected static ?int $navigationSort = 120;
protected static ?int $navigationSort = 30;
public static function canCreate(): bool
{
@@ -48,11 +49,22 @@ class TenantFeedbackResource extends Resource
return TenantFeedbackTable::configure($table);
}
public static function getNavigationLabel(): string
{
return __('admin.feedback.navigation.label');
}
public static function getNavigationGroup(): UnitEnum|string|null
{
return __('admin.nav.feedback_support');
}
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()
->with(['tenant', 'event', 'moderator']);
}
public static function getRelations(): array
{
return [];

View File

@@ -3,8 +3,8 @@
namespace App\Filament\Resources\TenantFeedbackResource\Schemas;
use Filament\Infolists\Components\KeyValueEntry;
use Filament\Infolists\Components\Section;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Illuminate\Support\Str;
@@ -57,6 +57,37 @@ class TenantFeedbackInfolist
->label(__('Metadata'))
->columnSpanFull(),
]),
Section::make(__('admin.feedback.sections.moderation'))
->columns(2)
->schema([
TextEntry::make('status')
->label(__('admin.feedback.fields.status'))
->badge()
->color(fn (?string $state) => match ($state) {
'resolved' => 'success',
'hidden' => 'gray',
'deleted' => 'danger',
default => 'warning',
})
->formatStateUsing(fn (?string $state) => match ($state) {
'pending' => __('admin.feedback.status.pending'),
'resolved' => __('admin.feedback.status.resolved'),
'hidden' => __('admin.feedback.status.hidden'),
'deleted' => __('admin.feedback.status.deleted'),
default => '—',
}),
TextEntry::make('moderator.name')
->label(__('admin.feedback.fields.moderated_by'))
->placeholder('—'),
TextEntry::make('moderated_at')
->label(__('admin.feedback.fields.moderated_at'))
->dateTime()
->placeholder('—'),
TextEntry::make('moderation_notes')
->label(__('admin.feedback.fields.moderation_notes'))
->placeholder('—')
->columnSpanFull(),
]),
]);
}
}

View File

@@ -3,10 +3,16 @@
namespace App\Filament\Resources\TenantFeedbackResource\Tables;
use App\Models\TenantFeedback;
use Filament\Actions\Action;
use Filament\Actions\BulkAction;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\ViewAction;
use Filament\Facades\Filament;
use Filament\Forms\Components\Textarea;
use Filament\Tables;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Str;
class TenantFeedbackTable
@@ -20,6 +26,17 @@ class TenantFeedbackTable
->label(__('Eingegangen'))
->since()
->sortable(),
Tables\Columns\TextColumn::make('status')
->label(__('admin.feedback.table.status'))
->badge()
->color(fn (?string $state) => match ($state) {
'resolved' => 'success',
'hidden' => 'gray',
'deleted' => 'danger',
default => 'warning',
})
->formatStateUsing(fn (?string $state) => self::statusLabels()[$state] ?? '—')
->sortable(),
Tables\Columns\TextColumn::make('tenant.name')
->label(__('Tenant'))
->searchable()
@@ -52,8 +69,21 @@ class TenantFeedbackTable
->label(__('Nachricht'))
->limit(60)
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('moderator.name')
->label(__('admin.feedback.table.moderated_by'))
->placeholder('—')
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('moderated_at')
->label(__('admin.feedback.table.moderated_at'))
->since()
->placeholder('—')
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
SelectFilter::make('status')
->label(__('admin.feedback.filters.status'))
->options(self::statusLabels())
->default('pending'),
SelectFilter::make('sentiment')
->label(__('Stimmung'))
->options([
@@ -71,7 +101,108 @@ class TenantFeedbackTable
])
->recordActions([
ViewAction::make(),
Action::make('resolve')
->label(__('admin.feedback.actions.resolve'))
->color('success')
->icon('heroicon-o-check-circle')
->visible(fn (TenantFeedback $record) => $record->status === 'pending')
->form([
self::moderationNotesField(false),
])
->requiresConfirmation()
->action(fn (TenantFeedback $record, array $data) => self::applyModeration($record, 'resolved', $data['moderation_notes'] ?? null)),
Action::make('hide')
->label(__('admin.feedback.actions.hide'))
->color('gray')
->icon('heroicon-o-eye-slash')
->visible(fn (TenantFeedback $record) => $record->status !== 'hidden' && $record->status !== 'deleted')
->form([
self::moderationNotesField(false),
])
->requiresConfirmation()
->action(fn (TenantFeedback $record, array $data) => self::applyModeration($record, 'hidden', $data['moderation_notes'] ?? null)),
Action::make('delete')
->label(__('admin.feedback.actions.delete'))
->color('danger')
->icon('heroicon-o-trash')
->visible(fn (TenantFeedback $record) => $record->status !== 'deleted')
->form([
self::moderationNotesField(true),
])
->requiresConfirmation()
->action(fn (TenantFeedback $record, array $data) => self::applyModeration($record, 'deleted', $data['moderation_notes'] ?? null)),
])
->bulkActions([]);
->toolbarActions([
BulkActionGroup::make([
BulkAction::make('resolve')
->label(__('admin.feedback.actions.resolve_selected'))
->icon('heroicon-o-check-circle')
->color('success')
->form([
self::moderationNotesField(false),
])
->requiresConfirmation()
->action(fn (Collection $records, array $data) => self::applyModerationToRecords($records, 'resolved', $data['moderation_notes'] ?? null)),
BulkAction::make('hide')
->label(__('admin.feedback.actions.hide_selected'))
->icon('heroicon-o-eye-slash')
->color('gray')
->form([
self::moderationNotesField(false),
])
->requiresConfirmation()
->action(fn (Collection $records, array $data) => self::applyModerationToRecords($records, 'hidden', $data['moderation_notes'] ?? null)),
BulkAction::make('delete')
->label(__('admin.feedback.actions.delete_selected'))
->icon('heroicon-o-trash')
->color('danger')
->form([
self::moderationNotesField(true),
])
->requiresConfirmation()
->action(fn (Collection $records, array $data) => self::applyModerationToRecords($records, 'deleted', $data['moderation_notes'] ?? null)),
]),
]);
}
private static function moderationNotesField(bool $required): Textarea
{
return Textarea::make('moderation_notes')
->label(__('admin.feedback.fields.moderation_notes'))
->maxLength(1000)
->rows(3)
->required($required);
}
private static function applyModeration(TenantFeedback $record, string $status, ?string $notes): void
{
$record->update([
'status' => $status,
'moderation_notes' => $notes,
'moderated_at' => now(),
'moderated_by' => Filament::auth()->id(),
]);
}
private static function applyModerationToRecords(Collection $records, string $status, ?string $notes): int
{
return TenantFeedback::query()
->whereIn('id', $records->pluck('id'))
->update([
'status' => $status,
'moderation_notes' => $notes,
'moderated_at' => now(),
'moderated_by' => Filament::auth()->id(),
]);
}
private static function statusLabels(): array
{
return [
'pending' => __('admin.feedback.status.pending'),
'resolved' => __('admin.feedback.status.resolved'),
'hidden' => __('admin.feedback.status.hidden'),
'deleted' => __('admin.feedback.status.deleted'),
];
}
}

View File

@@ -36,6 +36,7 @@ class Photo extends Model
'metadata' => 'array',
'security_meta' => 'array',
'security_scanned_at' => 'datetime',
'moderated_at' => 'datetime',
];
protected $attributes = [
@@ -73,6 +74,11 @@ class Photo extends Model
return $this->belongsTo(Task::class);
}
public function moderator(): BelongsTo
{
return $this->belongsTo(User::class, 'moderated_by');
}
public function likes(): HasMany
{
return $this->hasMany(PhotoLike::class);

View File

@@ -16,6 +16,7 @@ class TenantFeedback extends Model
protected $casts = [
'metadata' => 'array',
'moderated_at' => 'datetime',
];
public function tenant(): BelongsTo
@@ -27,4 +28,9 @@ class TenantFeedback extends Model
{
return $this->belongsTo(Event::class);
}
public function moderator(): BelongsTo
{
return $this->belongsTo(User::class, 'moderated_by');
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace Database\Factories;
use App\Models\Event;
use App\Models\Tenant;
use App\Models\TenantFeedback;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\TenantFeedback>
*/
class TenantFeedbackFactory extends Factory
{
protected $model = TenantFeedback::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'tenant_id' => Tenant::factory(),
'event_id' => Event::factory(),
'category' => $this->faker->randomElement(['bug', 'idea', 'praise', 'question']),
'sentiment' => $this->faker->randomElement(['positive', 'neutral', 'negative']),
'rating' => $this->faker->numberBetween(1, 5),
'title' => $this->faker->sentence(4),
'message' => $this->faker->paragraph(),
'metadata' => ['source' => 'factory'],
'status' => 'pending',
];
}
public function configure(): static
{
return $this->afterMaking(function (TenantFeedback $feedback) {
if ($feedback->event && ! $feedback->tenant_id) {
$feedback->tenant_id = $feedback->event->tenant_id;
}
})->afterCreating(function (TenantFeedback $feedback) {
if ($feedback->event && ! $feedback->tenant_id) {
$feedback->tenant_id = $feedback->event->tenant_id;
$feedback->save();
}
if ($feedback->event && $feedback->tenant_id && $feedback->event->tenant_id !== $feedback->tenant_id) {
$feedback->event->update(['tenant_id' => $feedback->tenant_id]);
}
});
}
}

View File

@@ -0,0 +1,50 @@
<?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('photos', function (Blueprint $table) {
if (! Schema::hasColumn('photos', 'moderation_notes')) {
$table->text('moderation_notes')->nullable()->after('status');
}
if (! Schema::hasColumn('photos', 'moderated_at')) {
$table->timestamp('moderated_at')->nullable()->after('moderation_notes');
}
if (! Schema::hasColumn('photos', 'moderated_by')) {
$table->foreignId('moderated_by')
->nullable()
->after('moderated_at')
->constrained('users')
->nullOnDelete();
}
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('photos', function (Blueprint $table) {
if (Schema::hasColumn('photos', 'moderated_by')) {
$table->dropConstrainedForeignId('moderated_by');
}
foreach (['moderated_at', 'moderation_notes'] as $column) {
if (Schema::hasColumn('photos', $column)) {
$table->dropColumn($column);
}
}
});
}
};

View File

@@ -0,0 +1,56 @@
<?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('tenant_feedback', function (Blueprint $table) {
if (! Schema::hasColumn('tenant_feedback', 'status')) {
$table->string('status', 32)->default('pending')->after('message');
$table->index('status', 'tenant_feedback_status_index');
}
if (! Schema::hasColumn('tenant_feedback', 'moderation_notes')) {
$table->text('moderation_notes')->nullable()->after('status');
}
if (! Schema::hasColumn('tenant_feedback', 'moderated_at')) {
$table->timestamp('moderated_at')->nullable()->after('moderation_notes');
}
if (! Schema::hasColumn('tenant_feedback', 'moderated_by')) {
$table->foreignId('moderated_by')
->nullable()
->after('moderated_at')
->constrained('users')
->nullOnDelete();
}
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('tenant_feedback', function (Blueprint $table) {
if (Schema::hasColumn('tenant_feedback', 'moderated_by')) {
$table->dropConstrainedForeignId('moderated_by');
}
foreach (['moderated_at', 'moderation_notes', 'status'] as $column) {
if (Schema::hasColumn('tenant_feedback', $column)) {
$table->dropColumn($column);
}
}
});
}
};

View File

@@ -75,6 +75,115 @@ return [
],
],
'moderation' => [
'navigation' => [
'label' => 'Moderations-Queue',
],
'sections' => [
'photo' => 'Foto',
'moderation' => 'Moderation',
],
'fields' => [
'photo' => 'Foto',
'event' => 'Veranstaltung',
'tenant' => 'Mandant',
'uploader' => 'Uploader',
'uploaded_at' => 'Hochgeladen',
'ingest_source' => 'Quelle',
'status' => 'Status',
'moderation_notes' => 'Moderationsnotizen',
'moderated_by' => 'Moderiert von',
'moderated_at' => 'Moderiert am',
'security_scan_status' => 'Sicherheits-Scan',
'security_scan_message' => 'Sicherheits-Scan Nachricht',
'security_scanned_at' => 'Scan-Zeitpunkt',
],
'table' => [
'photo' => 'Foto',
'event' => 'Veranstaltung',
'tenant' => 'Mandant',
'uploader' => 'Uploader',
'status' => 'Status',
'security_scan' => 'Sicherheits-Scan',
'ingest_source' => 'Quelle',
'uploaded_at' => 'Hochgeladen',
'moderated_by' => 'Moderator',
'moderated_at' => 'Moderiert',
],
'filters' => [
'status' => 'Status',
'ingest_source' => 'Quelle',
'security_scan_status' => 'Sicherheits-Scan',
'uploaded_at' => 'Hochgeladen am',
],
'actions' => [
'approve' => 'Freigeben',
'reject' => 'Ablehnen',
'hide' => 'Verstecken',
'approve_selected' => 'Auswahl freigeben',
'reject_selected' => 'Auswahl ablehnen',
'hide_selected' => 'Auswahl verstecken',
],
'status' => [
'pending' => 'Ausstehend',
'approved' => 'Freigegeben',
'rejected' => 'Abgelehnt',
'hidden' => 'Versteckt',
],
'ingest_sources' => [
'guest_pwa' => 'Guest PWA',
'tenant_admin' => 'Tenant Admin',
'photobooth' => 'Photobooth',
'sparkbooth' => 'Sparkbooth',
'unknown' => 'Unbekannt',
],
'security_scan' => [
'pending' => 'Ausstehend',
'clean' => 'Sauber',
'infected' => 'Infiziert',
'skipped' => 'Übersprungen',
'stripped' => 'Entfernt',
'error' => 'Fehler',
],
],
'feedback' => [
'navigation' => [
'label' => 'Feedback-Queue',
],
'sections' => [
'moderation' => 'Moderation',
],
'fields' => [
'status' => 'Status',
'moderation_notes' => 'Moderationsnotizen',
'moderated_by' => 'Moderiert von',
'moderated_at' => 'Moderiert am',
],
'table' => [
'status' => 'Status',
'moderated_by' => 'Moderator',
'moderated_at' => 'Moderiert',
],
'filters' => [
'status' => 'Status',
],
'actions' => [
'resolve' => 'Erledigen',
'hide' => 'Verstecken',
'delete' => 'Löschen',
'resolve_selected' => 'Auswahl erledigen',
'hide_selected' => 'Auswahl verstecken',
'delete_selected' => 'Auswahl löschen',
],
'status' => [
'pending' => 'Ausstehend',
'resolved' => 'Erledigt',
'hidden' => 'Versteckt',
'deleted' => 'Gelöscht',
],
],
'events' => [
'fields' => [
'tenant' => 'Mandant',

View File

@@ -75,6 +75,115 @@ return [
],
],
'moderation' => [
'navigation' => [
'label' => 'Moderation queue',
],
'sections' => [
'photo' => 'Photo',
'moderation' => 'Moderation',
],
'fields' => [
'photo' => 'Photo',
'event' => 'Event',
'tenant' => 'Tenant',
'uploader' => 'Uploader',
'uploaded_at' => 'Uploaded',
'ingest_source' => 'Ingest source',
'status' => 'Status',
'moderation_notes' => 'Moderation notes',
'moderated_by' => 'Moderated by',
'moderated_at' => 'Moderated at',
'security_scan_status' => 'Security scan',
'security_scan_message' => 'Security scan message',
'security_scanned_at' => 'Security scanned at',
],
'table' => [
'photo' => 'Photo',
'event' => 'Event',
'tenant' => 'Tenant',
'uploader' => 'Uploader',
'status' => 'Status',
'security_scan' => 'Security scan',
'ingest_source' => 'Source',
'uploaded_at' => 'Uploaded',
'moderated_by' => 'Moderator',
'moderated_at' => 'Moderated',
],
'filters' => [
'status' => 'Status',
'ingest_source' => 'Ingest source',
'security_scan_status' => 'Security scan',
'uploaded_at' => 'Uploaded at',
],
'actions' => [
'approve' => 'Approve',
'reject' => 'Reject',
'hide' => 'Hide',
'approve_selected' => 'Approve selected',
'reject_selected' => 'Reject selected',
'hide_selected' => 'Hide selected',
],
'status' => [
'pending' => 'Pending',
'approved' => 'Approved',
'rejected' => 'Rejected',
'hidden' => 'Hidden',
],
'ingest_sources' => [
'guest_pwa' => 'Guest PWA',
'tenant_admin' => 'Tenant admin',
'photobooth' => 'Photobooth',
'sparkbooth' => 'Sparkbooth',
'unknown' => 'Unknown',
],
'security_scan' => [
'pending' => 'Pending',
'clean' => 'Clean',
'infected' => 'Infected',
'skipped' => 'Skipped',
'stripped' => 'Stripped',
'error' => 'Error',
],
],
'feedback' => [
'navigation' => [
'label' => 'Feedback queue',
],
'sections' => [
'moderation' => 'Moderation',
],
'fields' => [
'status' => 'Status',
'moderation_notes' => 'Moderation notes',
'moderated_by' => 'Moderated by',
'moderated_at' => 'Moderated at',
],
'table' => [
'status' => 'Status',
'moderated_by' => 'Moderator',
'moderated_at' => 'Moderated',
],
'filters' => [
'status' => 'Status',
],
'actions' => [
'resolve' => 'Resolve',
'hide' => 'Hide',
'delete' => 'Delete',
'resolve_selected' => 'Resolve selected',
'hide_selected' => 'Hide selected',
'delete_selected' => 'Delete selected',
],
'status' => [
'pending' => 'Pending',
'resolved' => 'Resolved',
'hidden' => 'Hidden',
'deleted' => 'Deleted',
],
],
'events' => [
'fields' => [
'tenant' => 'Tenant',

View File

@@ -0,0 +1,89 @@
<?php
namespace Tests\Feature;
use App\Filament\Clusters\DailyOps\Resources\Photos\Pages\ListPhotos;
use App\Models\Event;
use App\Models\Photo;
use App\Models\Tenant;
use App\Models\User;
use Filament\Actions\Testing\TestAction;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;
class PhotoModerationQueueTest extends TestCase
{
use RefreshDatabase;
public function test_superadmin_can_approve_pending_photo(): void
{
$user = User::factory()->create(['role' => 'super_admin']);
$tenant = Tenant::factory()->create();
$event = Event::factory()->create(['tenant_id' => $tenant->id]);
$photo = Photo::factory()->for($event)->create([
'status' => 'pending',
'ingest_source' => Photo::SOURCE_GUEST_PWA,
]);
$this->bootSuperAdminPanel($user);
Livewire::test(ListPhotos::class)
->callAction(TestAction::make('approve')->table($photo), [
'moderation_notes' => 'Looks good.',
]);
$photo->refresh();
$this->assertSame('approved', $photo->status);
$this->assertSame('Looks good.', $photo->moderation_notes);
$this->assertNotNull($photo->moderated_at);
$this->assertSame($user->id, $photo->moderated_by);
}
public function test_superadmin_can_bulk_reject_pending_photos(): void
{
$user = User::factory()->create(['role' => 'super_admin']);
$tenant = Tenant::factory()->create();
$event = Event::factory()->create(['tenant_id' => $tenant->id]);
$photoA = Photo::factory()->for($event)->create([
'status' => 'pending',
'ingest_source' => Photo::SOURCE_GUEST_PWA,
]);
$photoB = Photo::factory()->for($event)->create([
'status' => 'pending',
'ingest_source' => Photo::SOURCE_GUEST_PWA,
]);
$this->bootSuperAdminPanel($user);
Livewire::test(ListPhotos::class)
->callTableBulkAction('reject', [$photoA, $photoB], [
'moderation_notes' => 'Policy violation.',
]);
$photoA->refresh();
$photoB->refresh();
$this->assertSame('rejected', $photoA->status);
$this->assertSame('rejected', $photoB->status);
$this->assertSame('Policy violation.', $photoA->moderation_notes);
$this->assertSame('Policy violation.', $photoB->moderation_notes);
$this->assertNotNull($photoA->moderated_at);
$this->assertNotNull($photoB->moderated_at);
$this->assertSame($user->id, $photoA->moderated_by);
$this->assertSame($user->id, $photoB->moderated_by);
}
private function bootSuperAdminPanel(User $user): void
{
$panel = Filament::getPanel('superadmin');
$this->assertNotNull($panel);
Filament::setCurrentPanel($panel);
Filament::bootCurrentPanel();
Filament::auth()->login($user);
}
}

View File

@@ -0,0 +1,74 @@
<?php
namespace Tests\Feature;
use App\Filament\Resources\TenantFeedbackResource\Pages\ListTenantFeedback;
use App\Models\TenantFeedback;
use App\Models\User;
use Filament\Actions\Testing\TestAction;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;
class TenantFeedbackModerationQueueTest extends TestCase
{
use RefreshDatabase;
public function test_superadmin_can_resolve_pending_feedback(): void
{
$user = User::factory()->create(['role' => 'super_admin']);
$feedback = TenantFeedback::factory()->create(['status' => 'pending']);
$this->bootSuperAdminPanel($user);
Livewire::test(ListTenantFeedback::class)
->callAction(TestAction::make('resolve')->table($feedback), [
'moderation_notes' => 'Handled by ops.',
]);
$feedback->refresh();
$this->assertSame('resolved', $feedback->status);
$this->assertSame('Handled by ops.', $feedback->moderation_notes);
$this->assertNotNull($feedback->moderated_at);
$this->assertSame($user->id, $feedback->moderated_by);
}
public function test_superadmin_can_bulk_delete_feedback(): void
{
$user = User::factory()->create(['role' => 'super_admin']);
$feedbackA = TenantFeedback::factory()->create(['status' => 'pending']);
$feedbackB = TenantFeedback::factory()->create(['status' => 'pending']);
$this->bootSuperAdminPanel($user);
Livewire::test(ListTenantFeedback::class)
->callTableBulkAction('delete', [$feedbackA, $feedbackB], [
'moderation_notes' => 'Removed due to abuse.',
]);
$feedbackA->refresh();
$feedbackB->refresh();
$this->assertSame('deleted', $feedbackA->status);
$this->assertSame('deleted', $feedbackB->status);
$this->assertSame('Removed due to abuse.', $feedbackA->moderation_notes);
$this->assertSame('Removed due to abuse.', $feedbackB->moderation_notes);
$this->assertNotNull($feedbackA->moderated_at);
$this->assertNotNull($feedbackB->moderated_at);
$this->assertSame($user->id, $feedbackA->moderated_by);
$this->assertSame($user->id, $feedbackB->moderated_by);
}
private function bootSuperAdminPanel(User $user): void
{
$panel = Filament::getPanel('superadmin');
$this->assertNotNull($panel);
Filament::setCurrentPanel($panel);
Filament::bootCurrentPanel();
Filament::auth()->login($user);
}
}