From 117250879b3f01c821d533b5988ca00af2467dab Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Thu, 1 Jan 2026 18:52:32 +0100 Subject: [PATCH] Add superadmin moderation queues --- .beads/issues.jsonl | 2 +- .beads/last-touched | 2 +- .../Resources/Photos/Pages/CreatePhoto.php | 11 + .../Resources/Photos/Pages/EditPhoto.php | 21 ++ .../Resources/Photos/Pages/ListPhotos.php | 16 ++ .../Resources/Photos/Pages/ViewPhoto.php | 16 ++ .../Resources/Photos/PhotoResource.php | 84 ++++++ .../Resources/Photos/Schemas/PhotoForm.php | 74 +++++ .../Photos/Schemas/PhotoInfolist.php | 109 ++++++++ .../Resources/Photos/Tables/PhotosTable.php | 261 ++++++++++++++++++ .../Resources/TenantFeedbackResource.php | 14 +- .../Schemas/TenantFeedbackInfolist.php | 33 ++- .../Tables/TenantFeedbackTable.php | 133 ++++++++- app/Models/Photo.php | 6 + app/Models/TenantFeedback.php | 6 + database/factories/TenantFeedbackFactory.php | 54 ++++ ...add_moderation_columns_to_photos_table.php | 50 ++++ ...ation_columns_to_tenant_feedback_table.php | 56 ++++ resources/lang/de/admin.php | 109 ++++++++ resources/lang/en/admin.php | 109 ++++++++ tests/Feature/PhotoModerationQueueTest.php | 89 ++++++ .../TenantFeedbackModerationQueueTest.php | 74 +++++ 22 files changed, 1324 insertions(+), 5 deletions(-) create mode 100644 app/Filament/Clusters/DailyOps/Resources/Photos/Pages/CreatePhoto.php create mode 100644 app/Filament/Clusters/DailyOps/Resources/Photos/Pages/EditPhoto.php create mode 100644 app/Filament/Clusters/DailyOps/Resources/Photos/Pages/ListPhotos.php create mode 100644 app/Filament/Clusters/DailyOps/Resources/Photos/Pages/ViewPhoto.php create mode 100644 app/Filament/Clusters/DailyOps/Resources/Photos/PhotoResource.php create mode 100644 app/Filament/Clusters/DailyOps/Resources/Photos/Schemas/PhotoForm.php create mode 100644 app/Filament/Clusters/DailyOps/Resources/Photos/Schemas/PhotoInfolist.php create mode 100644 app/Filament/Clusters/DailyOps/Resources/Photos/Tables/PhotosTable.php create mode 100644 database/factories/TenantFeedbackFactory.php create mode 100644 database/migrations/2026_01_01_180519_add_moderation_columns_to_photos_table.php create mode 100644 database/migrations/2026_01_01_183652_add_moderation_columns_to_tenant_feedback_table.php create mode 100644 tests/Feature/PhotoModerationQueueTest.php create mode 100644 tests/Feature/TenantFeedbackModerationQueueTest.php diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 580cfe2..3278c0b 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -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"} diff --git a/.beads/last-touched b/.beads/last-touched index ce57e07..cd49248 100644 --- a/.beads/last-touched +++ b/.beads/last-touched @@ -1 +1 @@ ---stealth-d39 +fotospiel-app-hbt diff --git a/app/Filament/Clusters/DailyOps/Resources/Photos/Pages/CreatePhoto.php b/app/Filament/Clusters/DailyOps/Resources/Photos/Pages/CreatePhoto.php new file mode 100644 index 0000000..9253664 --- /dev/null +++ b/app/Filament/Clusters/DailyOps/Resources/Photos/Pages/CreatePhoto.php @@ -0,0 +1,11 @@ +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}'), + ]; + } +} diff --git a/app/Filament/Clusters/DailyOps/Resources/Photos/Schemas/PhotoForm.php b/app/Filament/Clusters/DailyOps/Resources/Photos/Schemas/PhotoForm.php new file mode 100644 index 0000000..d4d27a5 --- /dev/null +++ b/app/Filament/Clusters/DailyOps/Resources/Photos/Schemas/PhotoForm.php @@ -0,0 +1,74 @@ +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'), + ]); + } +} diff --git a/app/Filament/Clusters/DailyOps/Resources/Photos/Schemas/PhotoInfolist.php b/app/Filament/Clusters/DailyOps/Resources/Photos/Schemas/PhotoInfolist.php new file mode 100644 index 0000000..fb49357 --- /dev/null +++ b/app/Filament/Clusters/DailyOps/Resources/Photos/Schemas/PhotoInfolist.php @@ -0,0 +1,109 @@ +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('—'), + ]), + ]); + } +} diff --git a/app/Filament/Clusters/DailyOps/Resources/Photos/Tables/PhotosTable.php b/app/Filament/Clusters/DailyOps/Resources/Photos/Tables/PhotosTable.php new file mode 100644 index 0000000..dd1b1d4 --- /dev/null +++ b/app/Filament/Clusters/DailyOps/Resources/Photos/Tables/PhotosTable.php @@ -0,0 +1,261 @@ +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'), + ]; + } +} diff --git a/app/Filament/Resources/TenantFeedbackResource.php b/app/Filament/Resources/TenantFeedbackResource.php index 7174224..b7c9a49 100644 --- a/app/Filament/Resources/TenantFeedbackResource.php +++ b/app/Filament/Resources/TenantFeedbackResource.php @@ -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 []; diff --git a/app/Filament/Resources/TenantFeedbackResource/Schemas/TenantFeedbackInfolist.php b/app/Filament/Resources/TenantFeedbackResource/Schemas/TenantFeedbackInfolist.php index cacd55e..c98173a 100644 --- a/app/Filament/Resources/TenantFeedbackResource/Schemas/TenantFeedbackInfolist.php +++ b/app/Filament/Resources/TenantFeedbackResource/Schemas/TenantFeedbackInfolist.php @@ -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(), + ]), ]); } } diff --git a/app/Filament/Resources/TenantFeedbackResource/Tables/TenantFeedbackTable.php b/app/Filament/Resources/TenantFeedbackResource/Tables/TenantFeedbackTable.php index 07d383a..17592e5 100644 --- a/app/Filament/Resources/TenantFeedbackResource/Tables/TenantFeedbackTable.php +++ b/app/Filament/Resources/TenantFeedbackResource/Tables/TenantFeedbackTable.php @@ -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'), + ]; } } diff --git a/app/Models/Photo.php b/app/Models/Photo.php index 2004592..ad9b6e6 100644 --- a/app/Models/Photo.php +++ b/app/Models/Photo.php @@ -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); diff --git a/app/Models/TenantFeedback.php b/app/Models/TenantFeedback.php index 0ac469c..4ddcfd2 100644 --- a/app/Models/TenantFeedback.php +++ b/app/Models/TenantFeedback.php @@ -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'); + } } diff --git a/database/factories/TenantFeedbackFactory.php b/database/factories/TenantFeedbackFactory.php new file mode 100644 index 0000000..3312dc9 --- /dev/null +++ b/database/factories/TenantFeedbackFactory.php @@ -0,0 +1,54 @@ + + */ +class TenantFeedbackFactory extends Factory +{ + protected $model = TenantFeedback::class; + + /** + * Define the model's default state. + * + * @return array + */ + 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]); + } + }); + } +} diff --git a/database/migrations/2026_01_01_180519_add_moderation_columns_to_photos_table.php b/database/migrations/2026_01_01_180519_add_moderation_columns_to_photos_table.php new file mode 100644 index 0000000..1b3763b --- /dev/null +++ b/database/migrations/2026_01_01_180519_add_moderation_columns_to_photos_table.php @@ -0,0 +1,50 @@ +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); + } + } + }); + } +}; diff --git a/database/migrations/2026_01_01_183652_add_moderation_columns_to_tenant_feedback_table.php b/database/migrations/2026_01_01_183652_add_moderation_columns_to_tenant_feedback_table.php new file mode 100644 index 0000000..94f6206 --- /dev/null +++ b/database/migrations/2026_01_01_183652_add_moderation_columns_to_tenant_feedback_table.php @@ -0,0 +1,56 @@ +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); + } + } + + }); + } +}; diff --git a/resources/lang/de/admin.php b/resources/lang/de/admin.php index 7028971..f0ea6fd 100644 --- a/resources/lang/de/admin.php +++ b/resources/lang/de/admin.php @@ -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', diff --git a/resources/lang/en/admin.php b/resources/lang/en/admin.php index 6b565e0..4c959a3 100644 --- a/resources/lang/en/admin.php +++ b/resources/lang/en/admin.php @@ -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', diff --git a/tests/Feature/PhotoModerationQueueTest.php b/tests/Feature/PhotoModerationQueueTest.php new file mode 100644 index 0000000..0116a66 --- /dev/null +++ b/tests/Feature/PhotoModerationQueueTest.php @@ -0,0 +1,89 @@ +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); + } +} diff --git a/tests/Feature/TenantFeedbackModerationQueueTest.php b/tests/Feature/TenantFeedbackModerationQueueTest.php new file mode 100644 index 0000000..810e1bd --- /dev/null +++ b/tests/Feature/TenantFeedbackModerationQueueTest.php @@ -0,0 +1,74 @@ +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); + } +}