Skip to content

Commit 00dfefb

Browse files
lessevvclaudesimonhamp
authored
Add plugin ratings and admin-only reporting (#495)
* feat(plugins): add rating and report data layer Adds PluginRating and PluginReport models with their own migrations, enums, policies, form requests, controllers, and routes. Free plugins can be rated by any logged-in user; paid plugins reuse the existing User::hasPluginAccess() check. Reports are a private admin-only channel (distinct from the existing developer messaging thread) capped at one open report per user per plugin. Also extracts the hardcoded support@nativephp.com address used across 7 notification call sites into config('mail.support_address'). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * feat(admin): add plugin report moderation and rating management Adds a Filament-only PluginReportResource with resolve/dismiss actions and a nav badge for open reports, plus an open-reports count column on the Plugins list and a RatingsRelationManager so admins can moderate abusive or fake ratings. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * feat(plugins): add rating and report UI to the public plugin page Shows the star average/count in the header, a rating widget for eligible logged-in users, and a collapsible "Report this plugin" panel. The report panel is not for bugs — it surfaces the plugin's GitHub Issues link (or its support channel) first, before the report form itself. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * test: cover plugin ratings and reports Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * feat(plugins): send users back to the plugin page after logging in to rate The "Log in to rate this plugin" link now passes the plugin page as a redirect param, which showLogin() stores as url.intended. Login, registration and GitHub sign-in all already end in redirect()->intended(), so all three routes back to the plugin. Only same-site paths are accepted so the param can't be used as an open redirect. Also hides the rating in the plugin header until there is at least one, dropping the "No ratings yet" line, and shows the average on the marketplace cards. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013QiSkWpoC7KpTegPeM1Z78 --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: Simon Hamp <simon.hamp@me.com>
1 parent d603019 commit 00dfefb

37 files changed

Lines changed: 1910 additions & 17 deletions

‎app/Enums/PluginReportCategory.php‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Enums;
6+
7+
enum PluginReportCategory: string
8+
{
9+
case MaliciousCode = 'malicious_code';
10+
case UnresponsiveAuthor = 'unresponsive_author';
11+
case Other = 'other';
12+
13+
public function label(): string
14+
{
15+
return match ($this) {
16+
self::MaliciousCode => 'Malicious or Unsafe Code',
17+
self::UnresponsiveAuthor => 'Unresponsive Author',
18+
self::Other => 'Other',
19+
};
20+
}
21+
}

‎app/Enums/PluginReportStatus.php‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Enums;
6+
7+
enum PluginReportStatus: string
8+
{
9+
case Open = 'open';
10+
case Resolved = 'resolved';
11+
case Dismissed = 'dismissed';
12+
13+
public function label(): string
14+
{
15+
return match ($this) {
16+
self::Open => 'Open',
17+
self::Resolved => 'Resolved',
18+
self::Dismissed => 'Dismissed',
19+
};
20+
}
21+
22+
public function color(): string
23+
{
24+
return match ($this) {
25+
self::Open => 'danger',
26+
self::Resolved => 'success',
27+
self::Dismissed => 'gray',
28+
};
29+
}
30+
}
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Filament\Resources;
6+
7+
use App\Enums\PluginReportCategory;
8+
use App\Enums\PluginReportStatus;
9+
use App\Filament\Resources\PluginReportResource\Pages;
10+
use App\Models\PluginReport;
11+
use Filament\Actions;
12+
use Filament\Forms;
13+
use Filament\Infolists;
14+
use Filament\Notifications\Notification;
15+
use Filament\Resources\Resource;
16+
use Filament\Schemas;
17+
use Filament\Schemas\Schema;
18+
use Filament\Tables;
19+
use Filament\Tables\Table;
20+
use Illuminate\Support\Facades\Auth;
21+
22+
final class PluginReportResource extends Resource
23+
{
24+
protected static ?string $model = PluginReport::class;
25+
26+
protected static \BackedEnum|string|null $navigationIcon = 'heroicon-o-flag';
27+
28+
protected static ?string $navigationLabel = 'Plugin Reports';
29+
30+
protected static \UnitEnum|string|null $navigationGroup = 'Products';
31+
32+
protected static ?int $navigationSort = 5;
33+
34+
protected static ?string $modelLabel = 'Plugin Report';
35+
36+
protected static ?string $pluralModelLabel = 'Plugin Reports';
37+
38+
protected static ?string $slug = 'plugin-reports';
39+
40+
public static function canCreate(): bool
41+
{
42+
return false;
43+
}
44+
45+
public static function getNavigationBadge(): ?string
46+
{
47+
$count = PluginReport::query()->open()->count();
48+
49+
return $count > 0 ? (string) $count : null;
50+
}
51+
52+
public static function getNavigationBadgeColor(): ?string
53+
{
54+
return 'danger';
55+
}
56+
57+
public static function form(Schema $schema): Schema
58+
{
59+
return $schema->schema([]);
60+
}
61+
62+
private static function resolutionActions(): array
63+
{
64+
return [
65+
Actions\Action::make('resolve')
66+
->label('Mark Resolved')
67+
->icon('heroicon-o-check-circle')
68+
->color('success')
69+
->visible(fn (PluginReport $record): bool => $record->isOpen())
70+
->form([
71+
Forms\Components\Textarea::make('resolution_note')
72+
->label('Resolution Note')
73+
->helperText('Optional internal note — not visible to the reporter or the plugin author.')
74+
->rows(3),
75+
])
76+
->action(function (PluginReport $record, array $data): void {
77+
$record->resolve(Auth::user(), $data['resolution_note'] ?: null);
78+
79+
Notification::make()
80+
->title('Report marked resolved')
81+
->success()
82+
->send();
83+
}),
84+
85+
Actions\Action::make('dismiss')
86+
->label('Dismiss')
87+
->icon('heroicon-o-x-circle')
88+
->color('gray')
89+
->visible(fn (PluginReport $record): bool => $record->isOpen())
90+
->requiresConfirmation()
91+
->modalDescription('Dismiss this report as not actionable? It will be removed from the open reports count.')
92+
->form([
93+
Forms\Components\Textarea::make('resolution_note')
94+
->label('Resolution Note')
95+
->helperText('Optional internal note — not visible to the reporter or the plugin author.')
96+
->rows(3),
97+
])
98+
->action(function (PluginReport $record, array $data): void {
99+
$record->dismiss(Auth::user(), $data['resolution_note'] ?: null);
100+
101+
Notification::make()
102+
->title('Report dismissed')
103+
->success()
104+
->send();
105+
}),
106+
];
107+
}
108+
109+
public static function infolist(Schema $schema): Schema
110+
{
111+
return $schema
112+
->inlineLabel()
113+
->columns(1)
114+
->schema([
115+
Schemas\Components\Section::make('Report Details')
116+
->inlineLabel()
117+
->columns(1)
118+
->schema([
119+
Infolists\Components\TextEntry::make('plugin.name')
120+
->label('Plugin'),
121+
Infolists\Components\TextEntry::make('user.email')
122+
->label('Reported By')
123+
->copyable(),
124+
Infolists\Components\TextEntry::make('category')
125+
->badge()
126+
->formatStateUsing(fn (PluginReportCategory $state): string => $state->label()),
127+
Infolists\Components\TextEntry::make('status')
128+
->badge()
129+
->color(fn (PluginReportStatus $state): string => $state->color())
130+
->formatStateUsing(fn (PluginReportStatus $state): string => $state->label()),
131+
Infolists\Components\TextEntry::make('message')
132+
->label('Report Message')
133+
->columnSpanFull(),
134+
Infolists\Components\TextEntry::make('created_at')
135+
->label('Filed At')
136+
->dateTime(),
137+
]),
138+
139+
Schemas\Components\Section::make('Resolution')
140+
->inlineLabel()
141+
->columns(1)
142+
->schema([
143+
Infolists\Components\TextEntry::make('resolvedBy.email')
144+
->label('Resolved By')
145+
->placeholder('—'),
146+
Infolists\Components\TextEntry::make('resolved_at')
147+
->label('Resolved At')
148+
->dateTime()
149+
->placeholder('—'),
150+
Infolists\Components\TextEntry::make('resolution_note')
151+
->label('Note')
152+
->placeholder('—')
153+
->columnSpanFull(),
154+
])
155+
->visible(fn (PluginReport $record): bool => ! $record->isOpen()),
156+
]);
157+
}
158+
159+
public static function table(Table $table): Table
160+
{
161+
return $table
162+
->columns([
163+
Tables\Columns\TextColumn::make('plugin.name')
164+
->label('Plugin')
165+
->searchable()
166+
->sortable(),
167+
168+
Tables\Columns\TextColumn::make('user.email')
169+
->label('Reported By')
170+
->searchable()
171+
->sortable(),
172+
173+
Tables\Columns\TextColumn::make('category')
174+
->badge()
175+
->formatStateUsing(fn (PluginReportCategory $state): string => $state->label())
176+
->sortable(),
177+
178+
Tables\Columns\TextColumn::make('status')
179+
->badge()
180+
->color(fn (PluginReportStatus $state): string => $state->color())
181+
->formatStateUsing(fn (PluginReportStatus $state): string => $state->label())
182+
->sortable(),
183+
184+
Tables\Columns\TextColumn::make('message')
185+
->limit(80)
186+
->wrap()
187+
->tooltip(fn (PluginReport $record): string => $record->message),
188+
189+
Tables\Columns\TextColumn::make('created_at')
190+
->label('Filed')
191+
->dateTime()
192+
->sortable(),
193+
])
194+
->filters([
195+
Tables\Filters\SelectFilter::make('status')
196+
->options(PluginReportStatus::class),
197+
Tables\Filters\SelectFilter::make('category')
198+
->options(PluginReportCategory::class),
199+
])
200+
->actions([
201+
Actions\ViewAction::make(),
202+
...self::resolutionActions(),
203+
])
204+
->bulkActions([])
205+
->defaultSort('created_at', 'desc')
206+
->recordUrl(
207+
fn ($record) => self::getUrl('view', ['record' => $record])
208+
);
209+
}
210+
211+
public static function getPages(): array
212+
{
213+
return [
214+
'index' => Pages\ListPluginReports::route('/'),
215+
'view' => Pages\ViewPluginReport::route('/{record}'),
216+
];
217+
}
218+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Filament\Resources\PluginReportResource\Pages;
6+
7+
use App\Filament\Resources\PluginReportResource;
8+
use Filament\Resources\Pages\ListRecords;
9+
10+
final class ListPluginReports extends ListRecords
11+
{
12+
protected static string $resource = PluginReportResource::class;
13+
14+
protected function getHeaderActions(): array
15+
{
16+
return [];
17+
}
18+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Filament\Resources\PluginReportResource\Pages;
6+
7+
use App\Filament\Resources\PluginReportResource;
8+
use App\Models\PluginReport;
9+
use Filament\Actions;
10+
use Filament\Forms;
11+
use Filament\Notifications\Notification;
12+
use Filament\Resources\Pages\ViewRecord;
13+
use Illuminate\Support\Facades\Auth;
14+
15+
final class ViewPluginReport extends ViewRecord
16+
{
17+
protected static string $resource = PluginReportResource::class;
18+
19+
protected function getHeaderActions(): array
20+
{
21+
return [
22+
Actions\Action::make('resolve')
23+
->label('Mark Resolved')
24+
->icon('heroicon-o-check-circle')
25+
->color('success')
26+
->visible(fn (): bool => $this->record instanceof PluginReport && $this->record->isOpen())
27+
->form([
28+
Forms\Components\Textarea::make('resolution_note')
29+
->label('Resolution Note')
30+
->helperText('Optional internal note — not visible to the reporter or the plugin author.')
31+
->rows(3),
32+
])
33+
->action(function (array $data): void {
34+
/** @var PluginReport $report */
35+
$report = $this->record;
36+
$report->resolve(Auth::user(), $data['resolution_note'] ?: null);
37+
38+
Notification::make()
39+
->title('Report marked resolved')
40+
->success()
41+
->send();
42+
43+
$this->refreshFormData(['status']);
44+
}),
45+
46+
Actions\Action::make('dismiss')
47+
->label('Dismiss')
48+
->icon('heroicon-o-x-circle')
49+
->color('gray')
50+
->visible(fn (): bool => $this->record instanceof PluginReport && $this->record->isOpen())
51+
->requiresConfirmation()
52+
->modalDescription('Dismiss this report as not actionable? It will be removed from the open reports count.')
53+
->form([
54+
Forms\Components\Textarea::make('resolution_note')
55+
->label('Resolution Note')
56+
->helperText('Optional internal note — not visible to the reporter or the plugin author.')
57+
->rows(3),
58+
])
59+
->action(function (array $data): void {
60+
/** @var PluginReport $report */
61+
$report = $this->record;
62+
$report->dismiss(Auth::user(), $data['resolution_note'] ?: null);
63+
64+
Notification::make()
65+
->title('Report dismissed')
66+
->success()
67+
->send();
68+
69+
$this->refreshFormData(['status']);
70+
}),
71+
];
72+
}
73+
}

‎app/Filament/Resources/PluginResource.php‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,9 @@ public static function form(Schema $schema): Schema
258258
public static function table(Table $table): Table
259259
{
260260
return $table
261+
->modifyQueryUsing(fn (Builder $query): Builder => $query->withCount([
262+
'reports as open_reports_count' => fn (Builder $query): Builder => $query->open(),
263+
]))
261264
->columns([
262265
Tables\Columns\ImageColumn::make('logo_path')
263266
->label('Logo')
@@ -307,6 +310,12 @@ public static function table(Table $table): Table
307310
->sortable()
308311
->toggleable(),
309312

313+
Tables\Columns\TextColumn::make('open_reports_count')
314+
->label('Reports')
315+
->badge()
316+
->color(fn (int $state): string => $state > 0 ? 'danger' : 'gray')
317+
->sortable(),
318+
310319
Tables\Columns\TextColumn::make('mobile_min_version')
311320
->label('Mobile SDK')
312321
->placeholder('-')
@@ -544,6 +553,7 @@ public static function getRelations(): array
544553
RelationManagers\PricesRelationManager::class,
545554
RelationManagers\LicensesRelationManager::class,
546555
RelationManagers\ActivitiesRelationManager::class,
556+
RelationManagers\RatingsRelationManager::class,
547557
];
548558
}
549559

0 commit comments

Comments
 (0)