Add superadmin moderation queues
This commit is contained in:
@@ -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-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-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-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-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-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"}
|
{"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"}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
--stealth-d39
|
fotospiel-app-hbt
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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('—'),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ use Filament\Resources\Resource;
|
|||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
use Filament\Support\Icons\Heroicon;
|
use Filament\Support\Icons\Heroicon;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
class TenantFeedbackResource extends Resource
|
class TenantFeedbackResource extends Resource
|
||||||
@@ -26,7 +27,7 @@ class TenantFeedbackResource extends Resource
|
|||||||
|
|
||||||
protected static UnitEnum|string|null $navigationGroup = null;
|
protected static UnitEnum|string|null $navigationGroup = null;
|
||||||
|
|
||||||
protected static ?int $navigationSort = 120;
|
protected static ?int $navigationSort = 30;
|
||||||
|
|
||||||
public static function canCreate(): bool
|
public static function canCreate(): bool
|
||||||
{
|
{
|
||||||
@@ -48,11 +49,22 @@ class TenantFeedbackResource extends Resource
|
|||||||
return TenantFeedbackTable::configure($table);
|
return TenantFeedbackTable::configure($table);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function getNavigationLabel(): string
|
||||||
|
{
|
||||||
|
return __('admin.feedback.navigation.label');
|
||||||
|
}
|
||||||
|
|
||||||
public static function getNavigationGroup(): UnitEnum|string|null
|
public static function getNavigationGroup(): UnitEnum|string|null
|
||||||
{
|
{
|
||||||
return __('admin.nav.feedback_support');
|
return __('admin.nav.feedback_support');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function getEloquentQuery(): Builder
|
||||||
|
{
|
||||||
|
return parent::getEloquentQuery()
|
||||||
|
->with(['tenant', 'event', 'moderator']);
|
||||||
|
}
|
||||||
|
|
||||||
public static function getRelations(): array
|
public static function getRelations(): array
|
||||||
{
|
{
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
namespace App\Filament\Resources\TenantFeedbackResource\Schemas;
|
namespace App\Filament\Resources\TenantFeedbackResource\Schemas;
|
||||||
|
|
||||||
use Filament\Infolists\Components\KeyValueEntry;
|
use Filament\Infolists\Components\KeyValueEntry;
|
||||||
use Filament\Infolists\Components\Section;
|
|
||||||
use Filament\Infolists\Components\TextEntry;
|
use Filament\Infolists\Components\TextEntry;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
@@ -57,6 +57,37 @@ class TenantFeedbackInfolist
|
|||||||
->label(__('Metadata'))
|
->label(__('Metadata'))
|
||||||
->columnSpanFull(),
|
->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(),
|
||||||
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,16 @@
|
|||||||
namespace App\Filament\Resources\TenantFeedbackResource\Tables;
|
namespace App\Filament\Resources\TenantFeedbackResource\Tables;
|
||||||
|
|
||||||
use App\Models\TenantFeedback;
|
use App\Models\TenantFeedback;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Actions\BulkAction;
|
||||||
|
use Filament\Actions\BulkActionGroup;
|
||||||
use Filament\Actions\ViewAction;
|
use Filament\Actions\ViewAction;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Filament\Forms\Components\Textarea;
|
||||||
use Filament\Tables;
|
use Filament\Tables;
|
||||||
use Filament\Tables\Filters\SelectFilter;
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class TenantFeedbackTable
|
class TenantFeedbackTable
|
||||||
@@ -20,6 +26,17 @@ class TenantFeedbackTable
|
|||||||
->label(__('Eingegangen'))
|
->label(__('Eingegangen'))
|
||||||
->since()
|
->since()
|
||||||
->sortable(),
|
->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')
|
Tables\Columns\TextColumn::make('tenant.name')
|
||||||
->label(__('Tenant'))
|
->label(__('Tenant'))
|
||||||
->searchable()
|
->searchable()
|
||||||
@@ -52,8 +69,21 @@ class TenantFeedbackTable
|
|||||||
->label(__('Nachricht'))
|
->label(__('Nachricht'))
|
||||||
->limit(60)
|
->limit(60)
|
||||||
->toggleable(isToggledHiddenByDefault: true),
|
->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([
|
->filters([
|
||||||
|
SelectFilter::make('status')
|
||||||
|
->label(__('admin.feedback.filters.status'))
|
||||||
|
->options(self::statusLabels())
|
||||||
|
->default('pending'),
|
||||||
SelectFilter::make('sentiment')
|
SelectFilter::make('sentiment')
|
||||||
->label(__('Stimmung'))
|
->label(__('Stimmung'))
|
||||||
->options([
|
->options([
|
||||||
@@ -71,7 +101,108 @@ class TenantFeedbackTable
|
|||||||
])
|
])
|
||||||
->recordActions([
|
->recordActions([
|
||||||
ViewAction::make(),
|
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'),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ class Photo extends Model
|
|||||||
'metadata' => 'array',
|
'metadata' => 'array',
|
||||||
'security_meta' => 'array',
|
'security_meta' => 'array',
|
||||||
'security_scanned_at' => 'datetime',
|
'security_scanned_at' => 'datetime',
|
||||||
|
'moderated_at' => 'datetime',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $attributes = [
|
protected $attributes = [
|
||||||
@@ -73,6 +74,11 @@ class Photo extends Model
|
|||||||
return $this->belongsTo(Task::class);
|
return $this->belongsTo(Task::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function moderator(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'moderated_by');
|
||||||
|
}
|
||||||
|
|
||||||
public function likes(): HasMany
|
public function likes(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(PhotoLike::class);
|
return $this->hasMany(PhotoLike::class);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class TenantFeedback extends Model
|
|||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'metadata' => 'array',
|
'metadata' => 'array',
|
||||||
|
'moderated_at' => 'datetime',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function tenant(): BelongsTo
|
public function tenant(): BelongsTo
|
||||||
@@ -27,4 +28,9 @@ class TenantFeedback extends Model
|
|||||||
{
|
{
|
||||||
return $this->belongsTo(Event::class);
|
return $this->belongsTo(Event::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function moderator(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'moderated_by');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
54
database/factories/TenantFeedbackFactory.php
Normal file
54
database/factories/TenantFeedbackFactory.php
Normal 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]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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' => [
|
'events' => [
|
||||||
'fields' => [
|
'fields' => [
|
||||||
'tenant' => 'Mandant',
|
'tenant' => 'Mandant',
|
||||||
|
|||||||
@@ -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' => [
|
'events' => [
|
||||||
'fields' => [
|
'fields' => [
|
||||||
'tenant' => 'Tenant',
|
'tenant' => 'Tenant',
|
||||||
|
|||||||
89
tests/Feature/PhotoModerationQueueTest.php
Normal file
89
tests/Feature/PhotoModerationQueueTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
74
tests/Feature/TenantFeedbackModerationQueueTest.php
Normal file
74
tests/Feature/TenantFeedbackModerationQueueTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user