diff --git a/app/Enums/PluginReportCategory.php b/app/Enums/PluginReportCategory.php new file mode 100644 index 000000000..a4ad032e6 --- /dev/null +++ b/app/Enums/PluginReportCategory.php @@ -0,0 +1,21 @@ + 'Malicious or Unsafe Code', + self::UnresponsiveAuthor => 'Unresponsive Author', + self::Other => 'Other', + }; + } +} diff --git a/app/Enums/PluginReportStatus.php b/app/Enums/PluginReportStatus.php new file mode 100644 index 000000000..bf7ee7ebf --- /dev/null +++ b/app/Enums/PluginReportStatus.php @@ -0,0 +1,30 @@ + 'Open', + self::Resolved => 'Resolved', + self::Dismissed => 'Dismissed', + }; + } + + public function color(): string + { + return match ($this) { + self::Open => 'danger', + self::Resolved => 'success', + self::Dismissed => 'gray', + }; + } +} diff --git a/app/Filament/Resources/PluginReportResource.php b/app/Filament/Resources/PluginReportResource.php new file mode 100644 index 000000000..7a3c7bd63 --- /dev/null +++ b/app/Filament/Resources/PluginReportResource.php @@ -0,0 +1,218 @@ +open()->count(); + + return $count > 0 ? (string) $count : null; + } + + public static function getNavigationBadgeColor(): ?string + { + return 'danger'; + } + + public static function form(Schema $schema): Schema + { + return $schema->schema([]); + } + + private static function resolutionActions(): array + { + return [ + Actions\Action::make('resolve') + ->label('Mark Resolved') + ->icon('heroicon-o-check-circle') + ->color('success') + ->visible(fn (PluginReport $record): bool => $record->isOpen()) + ->form([ + Forms\Components\Textarea::make('resolution_note') + ->label('Resolution Note') + ->helperText('Optional internal note — not visible to the reporter or the plugin author.') + ->rows(3), + ]) + ->action(function (PluginReport $record, array $data): void { + $record->resolve(Auth::user(), $data['resolution_note'] ?: null); + + Notification::make() + ->title('Report marked resolved') + ->success() + ->send(); + }), + + Actions\Action::make('dismiss') + ->label('Dismiss') + ->icon('heroicon-o-x-circle') + ->color('gray') + ->visible(fn (PluginReport $record): bool => $record->isOpen()) + ->requiresConfirmation() + ->modalDescription('Dismiss this report as not actionable? It will be removed from the open reports count.') + ->form([ + Forms\Components\Textarea::make('resolution_note') + ->label('Resolution Note') + ->helperText('Optional internal note — not visible to the reporter or the plugin author.') + ->rows(3), + ]) + ->action(function (PluginReport $record, array $data): void { + $record->dismiss(Auth::user(), $data['resolution_note'] ?: null); + + Notification::make() + ->title('Report dismissed') + ->success() + ->send(); + }), + ]; + } + + public static function infolist(Schema $schema): Schema + { + return $schema + ->inlineLabel() + ->columns(1) + ->schema([ + Schemas\Components\Section::make('Report Details') + ->inlineLabel() + ->columns(1) + ->schema([ + Infolists\Components\TextEntry::make('plugin.name') + ->label('Plugin'), + Infolists\Components\TextEntry::make('user.email') + ->label('Reported By') + ->copyable(), + Infolists\Components\TextEntry::make('category') + ->badge() + ->formatStateUsing(fn (PluginReportCategory $state): string => $state->label()), + Infolists\Components\TextEntry::make('status') + ->badge() + ->color(fn (PluginReportStatus $state): string => $state->color()) + ->formatStateUsing(fn (PluginReportStatus $state): string => $state->label()), + Infolists\Components\TextEntry::make('message') + ->label('Report Message') + ->columnSpanFull(), + Infolists\Components\TextEntry::make('created_at') + ->label('Filed At') + ->dateTime(), + ]), + + Schemas\Components\Section::make('Resolution') + ->inlineLabel() + ->columns(1) + ->schema([ + Infolists\Components\TextEntry::make('resolvedBy.email') + ->label('Resolved By') + ->placeholder('—'), + Infolists\Components\TextEntry::make('resolved_at') + ->label('Resolved At') + ->dateTime() + ->placeholder('—'), + Infolists\Components\TextEntry::make('resolution_note') + ->label('Note') + ->placeholder('—') + ->columnSpanFull(), + ]) + ->visible(fn (PluginReport $record): bool => ! $record->isOpen()), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('plugin.name') + ->label('Plugin') + ->searchable() + ->sortable(), + + Tables\Columns\TextColumn::make('user.email') + ->label('Reported By') + ->searchable() + ->sortable(), + + Tables\Columns\TextColumn::make('category') + ->badge() + ->formatStateUsing(fn (PluginReportCategory $state): string => $state->label()) + ->sortable(), + + Tables\Columns\TextColumn::make('status') + ->badge() + ->color(fn (PluginReportStatus $state): string => $state->color()) + ->formatStateUsing(fn (PluginReportStatus $state): string => $state->label()) + ->sortable(), + + Tables\Columns\TextColumn::make('message') + ->limit(80) + ->wrap() + ->tooltip(fn (PluginReport $record): string => $record->message), + + Tables\Columns\TextColumn::make('created_at') + ->label('Filed') + ->dateTime() + ->sortable(), + ]) + ->filters([ + Tables\Filters\SelectFilter::make('status') + ->options(PluginReportStatus::class), + Tables\Filters\SelectFilter::make('category') + ->options(PluginReportCategory::class), + ]) + ->actions([ + Actions\ViewAction::make(), + ...self::resolutionActions(), + ]) + ->bulkActions([]) + ->defaultSort('created_at', 'desc') + ->recordUrl( + fn ($record) => self::getUrl('view', ['record' => $record]) + ); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListPluginReports::route('/'), + 'view' => Pages\ViewPluginReport::route('/{record}'), + ]; + } +} diff --git a/app/Filament/Resources/PluginReportResource/Pages/ListPluginReports.php b/app/Filament/Resources/PluginReportResource/Pages/ListPluginReports.php new file mode 100644 index 000000000..2710e9efe --- /dev/null +++ b/app/Filament/Resources/PluginReportResource/Pages/ListPluginReports.php @@ -0,0 +1,18 @@ +label('Mark Resolved') + ->icon('heroicon-o-check-circle') + ->color('success') + ->visible(fn (): bool => $this->record instanceof PluginReport && $this->record->isOpen()) + ->form([ + Forms\Components\Textarea::make('resolution_note') + ->label('Resolution Note') + ->helperText('Optional internal note — not visible to the reporter or the plugin author.') + ->rows(3), + ]) + ->action(function (array $data): void { + /** @var PluginReport $report */ + $report = $this->record; + $report->resolve(Auth::user(), $data['resolution_note'] ?: null); + + Notification::make() + ->title('Report marked resolved') + ->success() + ->send(); + + $this->refreshFormData(['status']); + }), + + Actions\Action::make('dismiss') + ->label('Dismiss') + ->icon('heroicon-o-x-circle') + ->color('gray') + ->visible(fn (): bool => $this->record instanceof PluginReport && $this->record->isOpen()) + ->requiresConfirmation() + ->modalDescription('Dismiss this report as not actionable? It will be removed from the open reports count.') + ->form([ + Forms\Components\Textarea::make('resolution_note') + ->label('Resolution Note') + ->helperText('Optional internal note — not visible to the reporter or the plugin author.') + ->rows(3), + ]) + ->action(function (array $data): void { + /** @var PluginReport $report */ + $report = $this->record; + $report->dismiss(Auth::user(), $data['resolution_note'] ?: null); + + Notification::make() + ->title('Report dismissed') + ->success() + ->send(); + + $this->refreshFormData(['status']); + }), + ]; + } +} diff --git a/app/Filament/Resources/PluginResource.php b/app/Filament/Resources/PluginResource.php index e1cec0716..79f5ff4c1 100644 --- a/app/Filament/Resources/PluginResource.php +++ b/app/Filament/Resources/PluginResource.php @@ -258,6 +258,9 @@ public static function form(Schema $schema): Schema public static function table(Table $table): Table { return $table + ->modifyQueryUsing(fn (Builder $query): Builder => $query->withCount([ + 'reports as open_reports_count' => fn (Builder $query): Builder => $query->open(), + ])) ->columns([ Tables\Columns\ImageColumn::make('logo_path') ->label('Logo') @@ -307,6 +310,12 @@ public static function table(Table $table): Table ->sortable() ->toggleable(), + Tables\Columns\TextColumn::make('open_reports_count') + ->label('Reports') + ->badge() + ->color(fn (int $state): string => $state > 0 ? 'danger' : 'gray') + ->sortable(), + Tables\Columns\TextColumn::make('mobile_min_version') ->label('Mobile SDK') ->placeholder('-') @@ -544,6 +553,7 @@ public static function getRelations(): array RelationManagers\PricesRelationManager::class, RelationManagers\LicensesRelationManager::class, RelationManagers\ActivitiesRelationManager::class, + RelationManagers\RatingsRelationManager::class, ]; } diff --git a/app/Filament/Resources/PluginResource/RelationManagers/RatingsRelationManager.php b/app/Filament/Resources/PluginResource/RelationManagers/RatingsRelationManager.php new file mode 100644 index 000000000..05ce0a124 --- /dev/null +++ b/app/Filament/Resources/PluginResource/RelationManagers/RatingsRelationManager.php @@ -0,0 +1,56 @@ +columns([ + Tables\Columns\TextColumn::make('rating') + ->label('Stars') + ->formatStateUsing(fn (int $state): string => str_repeat('★', $state).str_repeat('☆', 5 - $state)) + ->sortable(), + + Tables\Columns\TextColumn::make('user.email') + ->label('User') + ->searchable(), + + Tables\Columns\TextColumn::make('created_at') + ->label('Rated') + ->dateTime() + ->sortable(), + + Tables\Columns\TextColumn::make('updated_at') + ->label('Last Changed') + ->dateTime() + ->sortable(), + ]) + ->headerActions([]) + ->actions([ + Actions\DeleteAction::make() + ->label('Remove') + ->modalHeading('Remove Rating') + ->modalDescription('This will delete the rating and recalculate the plugin\'s average. Use this to moderate abusive or fake ratings.'), + ]) + ->bulkActions([ + Actions\BulkActionGroup::make([ + Actions\DeleteBulkAction::make(), + ]), + ]) + ->defaultSort('created_at', 'desc') + ->paginated([10, 25, 50]); + } +} diff --git a/app/Http/Controllers/Auth/CustomerAuthController.php b/app/Http/Controllers/Auth/CustomerAuthController.php index 880ca4c65..7e2847f87 100644 --- a/app/Http/Controllers/Auth/CustomerAuthController.php +++ b/app/Http/Controllers/Auth/CustomerAuthController.php @@ -23,8 +23,10 @@ class CustomerAuthController extends Controller { public function __construct(protected CartService $cartService) {} - public function showLogin(): View + public function showLogin(Request $request): View { + $this->rememberIntendedUrl($request); + return view('auth.login'); } @@ -172,6 +174,27 @@ function ($user, $password): void { : back()->withErrors(['email' => [__($status)]]); } + /** + * Remember where the user was headed, so login sends them back there. + * + * Only same-site paths are accepted; anything else is ignored so the + * `redirect` parameter can't be used as an open redirect. + */ + private function rememberIntendedUrl(Request $request): void + { + $redirect = $request->query('redirect'); + + if (! is_string($redirect) || ! str_starts_with($redirect, '/')) { + return; + } + + if (str_starts_with($redirect, '//') || str_starts_with($redirect, '/\\')) { + return; + } + + session(['url.intended' => url($redirect)]); + } + private function acceptPendingTeamInvitation(User $user): void { $token = session()->pull('pending_team_invitation_token'); diff --git a/app/Http/Controllers/PluginRatingController.php b/app/Http/Controllers/PluginRatingController.php new file mode 100644 index 000000000..47027408e --- /dev/null +++ b/app/Http/Controllers/PluginRatingController.php @@ -0,0 +1,39 @@ +authorize('create', [PluginRating::class, $plugin]); + + PluginRating::submit($plugin, $request->user(), (int) $request->validated('rating')); + + return back()->with('success', 'Thanks for rating this plugin!'); + } + + public function destroy(Request $request, string $vendor, string $package): RedirectResponse + { + $plugin = Plugin::findByVendorPackageOrFail($vendor, $package); + $rating = PluginRating::findFor($plugin, $request->user()); + + abort_if(! $rating, 404); + + $this->authorize('delete', $rating); + + $rating->delete(); + + return back()->with('success', 'Your rating has been removed.'); + } +} diff --git a/app/Http/Controllers/PluginReportController.php b/app/Http/Controllers/PluginReportController.php new file mode 100644 index 000000000..311f9cfbf --- /dev/null +++ b/app/Http/Controllers/PluginReportController.php @@ -0,0 +1,36 @@ +authorize('create', [PluginReport::class, $plugin]); + + $user = $request->user(); + + if (PluginReport::hasOpenFor($plugin, $user)) { + return back()->with('error', 'You already have an open report for this plugin — our team will follow up soon.'); + } + + PluginReport::file( + $plugin, + $user, + PluginReportCategory::from($request->validated('category')), + $request->validated('message') + ); + + return back()->with('success', 'Thanks — your report has been sent to the NativePHP team.'); + } +} diff --git a/app/Http/Requests/StorePluginRatingRequest.php b/app/Http/Requests/StorePluginRatingRequest.php new file mode 100644 index 000000000..6b473fe7b --- /dev/null +++ b/app/Http/Requests/StorePluginRatingRequest.php @@ -0,0 +1,30 @@ + ['required', 'integer', 'between:1,5'], + ]; + } + + public function messages(): array + { + return [ + 'rating.required' => 'Please select a star rating.', + 'rating.between' => 'Ratings must be between 1 and 5 stars.', + ]; + } +} diff --git a/app/Http/Requests/StorePluginReportRequest.php b/app/Http/Requests/StorePluginReportRequest.php new file mode 100644 index 000000000..19204f5ac --- /dev/null +++ b/app/Http/Requests/StorePluginReportRequest.php @@ -0,0 +1,34 @@ + ['required', Rule::enum(PluginReportCategory::class)], + 'message' => ['required', 'string', 'max:5000'], + ]; + } + + public function messages(): array + { + return [ + 'category.required' => 'Please select a reason for this report.', + 'message.required' => 'Please describe the issue.', + 'message.max' => 'The message cannot be longer than 5000 characters.', + ]; + } +} diff --git a/app/Livewire/Customer/Plugins/Show.php b/app/Livewire/Customer/Plugins/Show.php index 12f365c65..1fd425d4a 100644 --- a/app/Livewire/Customer/Plugins/Show.php +++ b/app/Livewire/Customer/Plugins/Show.php @@ -204,7 +204,7 @@ public function submitForReview(): void // Notify $user->notify(new PluginSubmitted($this->plugin)); - Notification::route('mail', 'support@nativephp.com') + Notification::route('mail', config('mail.support_address')) ->notify(new PluginPendingReview($this->plugin)); Flux::toast(variant: 'success', text: 'Your plugin has been submitted for review!'); diff --git a/app/Livewire/Customer/Support/Create.php b/app/Livewire/Customer/Support/Create.php index 2ad91a01f..19141e93d 100644 --- a/app/Livewire/Customer/Support/Create.php +++ b/app/Livewire/Customer/Support/Create.php @@ -164,7 +164,7 @@ public function submit(): void $ticket->update(['attachments' => $attachments]); } - Notification::route('mail', 'support@nativephp.com') + Notification::route('mail', config('mail.support_address')) ->notify(new SupportTicketSubmitted($ticket)); $this->redirect(route('customer.support.tickets.show', $ticket), navigate: false); diff --git a/app/Livewire/Customer/Support/Show.php b/app/Livewire/Customer/Support/Show.php index 8e6337e23..570baaaff 100644 --- a/app/Livewire/Customer/Support/Show.php +++ b/app/Livewire/Customer/Support/Show.php @@ -81,7 +81,7 @@ public function reply(): void 'attachments' => $attachments, ]); - Notification::route('mail', 'support@nativephp.com') + Notification::route('mail', config('mail.support_address')) ->notify(new SupportTicketUserReplied($this->supportTicket, $reply)); $this->replyMessage = ''; diff --git a/app/Livewire/ShowcaseSubmissionForm.php b/app/Livewire/ShowcaseSubmissionForm.php index 454da2c71..eb5a8661b 100644 --- a/app/Livewire/ShowcaseSubmissionForm.php +++ b/app/Livewire/ShowcaseSubmissionForm.php @@ -169,7 +169,7 @@ public function submit(): mixed 'approved_by' => null, ]); - Notification::route('mail', 'support@nativephp.com') + Notification::route('mail', config('mail.support_address')) ->notify(new ShowcaseSubmitted($this->showcase, resubmitted: true)); return to_route('customer.showcase.index') @@ -185,7 +185,7 @@ public function submit(): mixed ...$data, ]); - Notification::route('mail', 'support@nativephp.com') + Notification::route('mail', config('mail.support_address')) ->notify(new ShowcaseSubmitted($showcase)); return to_route('customer.showcase.index') diff --git a/app/Livewire/WallOfLoveSubmissionForm.php b/app/Livewire/WallOfLoveSubmissionForm.php index f4348a9af..cc311b72d 100644 --- a/app/Livewire/WallOfLoveSubmissionForm.php +++ b/app/Livewire/WallOfLoveSubmissionForm.php @@ -103,7 +103,7 @@ public function submit(): mixed 'testimonial' => $this->testimonial ?: null, ]); - Notification::route('mail', 'support@nativephp.com') + Notification::route('mail', config('mail.support_address')) ->notify(new WallOfLoveSubmitted($submission)); return to_route('dashboard')->with('success', 'Thank you! Your submission has been received and is awaiting review.'); diff --git a/app/Models/Plugin.php b/app/Models/Plugin.php index 3a0178f6e..bbb9dd1ee 100644 --- a/app/Models/Plugin.php +++ b/app/Models/Plugin.php @@ -241,6 +241,22 @@ public function licenses(): HasMany return $this->hasMany(PluginLicense::class); } + /** + * @return HasMany + */ + public function ratings(): HasMany + { + return $this->hasMany(PluginRating::class); + } + + /** + * @return HasMany + */ + public function reports(): HasMany + { + return $this->hasMany(PluginReport::class); + } + /** * @return BelongsToMany */ @@ -677,6 +693,21 @@ public function getRepositoryOwnerAndName(): ?array ]; } + public function getIssuesUrl(): ?string + { + if (! $this->repository_url || ! str_contains($this->repository_url, 'github.com')) { + return null; + } + + $repoInfo = $this->getRepositoryOwnerAndName(); + + if (! $repoInfo) { + return null; + } + + return "https://github.com/{$repoInfo['owner']}/{$repoInfo['repo']}/issues"; + } + public function approve(int $approvedById): void { $previousStatus = $this->status; @@ -854,12 +885,20 @@ public function messageAdmins(string $message, ?int $causerId = null): PluginAct 'causer_id' => $causerId ?? $this->user_id, ]); - Notification::route('mail', 'support@nativephp.com') + Notification::route('mail', config('mail.support_address')) ->notify(new PluginDeveloperReplied($this, $activity)); return $activity; } + public function recalculateRating(): void + { + $this->update([ + 'rating_count' => $this->ratings()->count(), + 'rating_average' => $this->ratings()->avg('rating'), + ]); + } + public function updateDescription(string $description, int $updatedById): void { $oldDescription = $this->description; @@ -911,6 +950,8 @@ protected function casts(): array 'webhook_installed' => 'boolean', 'review_checks' => 'array', 'reviewed_at' => 'datetime', + 'rating_average' => 'decimal:2', + 'rating_count' => 'integer', ]; } } diff --git a/app/Models/PluginRating.php b/app/Models/PluginRating.php new file mode 100644 index 000000000..da1b5752f --- /dev/null +++ b/app/Models/PluginRating.php @@ -0,0 +1,66 @@ +plugin->recalculateRating(); + }); + + self::deleted(function (PluginRating $rating): void { + $rating->plugin->recalculateRating(); + }); + } + + public static function findFor(Plugin $plugin, User $user): ?self + { + return self::query() + ->where('plugin_id', $plugin->id) + ->where('user_id', $user->id) + ->first(); + } + + public static function submit(Plugin $plugin, User $user, int $rating): self + { + return self::query()->updateOrCreate( + ['plugin_id' => $plugin->id, 'user_id' => $user->id], + ['rating' => $rating] + ); + } + + /** + * @return BelongsTo + */ + public function plugin(): BelongsTo + { + return $this->belongsTo(Plugin::class); + } + + /** + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + protected function casts(): array + { + return [ + 'rating' => 'integer', + ]; + } +} diff --git a/app/Models/PluginReport.php b/app/Models/PluginReport.php new file mode 100644 index 000000000..31e7a2a89 --- /dev/null +++ b/app/Models/PluginReport.php @@ -0,0 +1,115 @@ + $query + * @return Builder + */ + #[Scope] + protected function open(Builder $query): Builder + { + return $query->where('status', PluginReportStatus::Open); + } + + public static function hasOpenFor(Plugin $plugin, User $user): bool + { + return self::query() + ->where('plugin_id', $plugin->id) + ->where('user_id', $user->id) + ->open() + ->exists(); + } + + public static function file(Plugin $plugin, User $user, PluginReportCategory $category, string $message): self + { + $report = self::query()->create([ + 'plugin_id' => $plugin->id, + 'user_id' => $user->id, + 'category' => $category, + 'message' => $message, + 'status' => PluginReportStatus::Open, + ]); + + Notification::route('mail', config('mail.support_address')) + ->notify(new PluginReported($report)); + + return $report; + } + + /** + * @return BelongsTo + */ + public function plugin(): BelongsTo + { + return $this->belongsTo(Plugin::class); + } + + /** + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * @return BelongsTo + */ + public function resolvedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'resolved_by'); + } + + public function isOpen(): bool + { + return $this->status === PluginReportStatus::Open; + } + + public function resolve(User $admin, ?string $note = null): void + { + $this->update([ + 'status' => PluginReportStatus::Resolved, + 'resolution_note' => $note, + 'resolved_by' => $admin->id, + 'resolved_at' => now(), + ]); + } + + public function dismiss(User $admin, ?string $note = null): void + { + $this->update([ + 'status' => PluginReportStatus::Dismissed, + 'resolution_note' => $note, + 'resolved_by' => $admin->id, + 'resolved_at' => now(), + ]); + } + + protected function casts(): array + { + return [ + 'category' => PluginReportCategory::class, + 'status' => PluginReportStatus::class, + 'resolved_at' => 'datetime', + ]; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index dd144d681..0e401fde6 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -476,6 +476,16 @@ public function hasPluginAccess(Plugin $plugin): bool return false; } + public function canRatePlugin(Plugin $plugin): bool + { + return $plugin->user_id !== $this->id && $this->hasPluginAccess($plugin); + } + + public function canReportPlugin(Plugin $plugin): bool + { + return $plugin->user_id !== $this->id; + } + public function getGitHubToken(): ?string { if (! $this->github_token) { diff --git a/app/Notifications/PluginReported.php b/app/Notifications/PluginReported.php new file mode 100644 index 000000000..3cb9b64e5 --- /dev/null +++ b/app/Notifications/PluginReported.php @@ -0,0 +1,45 @@ + + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + public function toMail(object $notifiable): MailMessage + { + $report = $this->report->loadMissing(['plugin', 'user']); + + return (new MailMessage) + ->subject('Plugin Reported: '.$report->plugin->name) + ->greeting('A plugin has been reported.') + ->line("**Plugin:** {$report->plugin->name}") + ->line('**Reported by:** '.$report->user->email) + ->line('**Reason:** '.$report->category->label()) + ->line('**Message:**') + ->line(Str::limit($report->message, 500)) + ->action('Review Report', PluginReportResource::getUrl('view', ['record' => $report])); + } +} diff --git a/app/Policies/PluginRatingPolicy.php b/app/Policies/PluginRatingPolicy.php new file mode 100644 index 000000000..a9b025def --- /dev/null +++ b/app/Policies/PluginRatingPolicy.php @@ -0,0 +1,27 @@ +canRatePlugin($plugin); + } + + public function update(User $user, PluginRating $pluginRating): bool + { + return $user->id === $pluginRating->user_id; + } + + public function delete(User $user, PluginRating $pluginRating): bool + { + return $user->id === $pluginRating->user_id || $user->isAdmin(); + } +} diff --git a/app/Policies/PluginReportPolicy.php b/app/Policies/PluginReportPolicy.php new file mode 100644 index 000000000..e197a4157 --- /dev/null +++ b/app/Policies/PluginReportPolicy.php @@ -0,0 +1,30 @@ +isAdmin(); + } + + public function view(User $user, PluginReport $pluginReport): Response + { + return $user->isAdmin() + ? Response::allow() + : Response::denyAsNotFound('Report not found.'); + } + + public function create(User $user, Plugin $plugin): bool + { + return $user->canReportPlugin($plugin); + } +} diff --git a/config/mail.php b/config/mail.php index e652bd025..d8984256d 100644 --- a/config/mail.php +++ b/config/mail.php @@ -103,6 +103,19 @@ 'name' => env('MAIL_FROM_NAME', 'Example'), ], + /* + |-------------------------------------------------------------------------- + | Support Address + |-------------------------------------------------------------------------- + | + | The mailbox that admin-facing notifications (support tickets, plugin + | messages, plugin reports, showcase/wall-of-love submissions, etc.) are + | routed to via Notification::route('mail', ...). + | + */ + + 'support_address' => env('MAIL_SUPPORT_ADDRESS', 'support@nativephp.com'), + /* |-------------------------------------------------------------------------- | Markdown Mail Settings diff --git a/database/factories/PluginRatingFactory.php b/database/factories/PluginRatingFactory.php new file mode 100644 index 000000000..9698bb4bf --- /dev/null +++ b/database/factories/PluginRatingFactory.php @@ -0,0 +1,28 @@ + + */ +class PluginRatingFactory extends Factory +{ + protected $model = PluginRating::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'plugin_id' => Plugin::factory(), + 'user_id' => User::factory(), + 'rating' => $this->faker->numberBetween(1, 5), + ]; + } +} diff --git a/database/factories/PluginReportFactory.php b/database/factories/PluginReportFactory.php new file mode 100644 index 000000000..9596b2c12 --- /dev/null +++ b/database/factories/PluginReportFactory.php @@ -0,0 +1,50 @@ + + */ +class PluginReportFactory extends Factory +{ + protected $model = PluginReport::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'plugin_id' => Plugin::factory(), + 'user_id' => User::factory(), + 'category' => $this->faker->randomElement(PluginReportCategory::cases()), + 'message' => $this->faker->paragraph(), + 'status' => PluginReportStatus::Open, + ]; + } + + public function resolved(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => PluginReportStatus::Resolved, + 'resolved_at' => now(), + 'resolved_by' => User::factory(), + ]); + } + + public function dismissed(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => PluginReportStatus::Dismissed, + 'resolved_at' => now(), + 'resolved_by' => User::factory(), + ]); + } +} diff --git a/database/migrations/2026_09_04_120000_create_plugin_ratings_table.php b/database/migrations/2026_09_04_120000_create_plugin_ratings_table.php new file mode 100644 index 000000000..a003be888 --- /dev/null +++ b/database/migrations/2026_09_04_120000_create_plugin_ratings_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('plugin_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->unsignedTinyInteger('rating'); + $table->timestamps(); + + $table->unique(['plugin_id', 'user_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('plugin_ratings'); + } +}; diff --git a/database/migrations/2026_09_04_120001_add_rating_average_and_rating_count_to_plugins_table.php b/database/migrations/2026_09_04_120001_add_rating_average_and_rating_count_to_plugins_table.php new file mode 100644 index 000000000..fc19e9a36 --- /dev/null +++ b/database/migrations/2026_09_04_120001_add_rating_average_and_rating_count_to_plugins_table.php @@ -0,0 +1,29 @@ +decimal('rating_average', 3, 2)->nullable()->after('works_in_jump'); + $table->unsignedInteger('rating_count')->default(0)->after('rating_average'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('plugins', function (Blueprint $table) { + $table->dropColumn(['rating_average', 'rating_count']); + }); + } +}; diff --git a/database/migrations/2026_09_04_120002_create_plugin_reports_table.php b/database/migrations/2026_09_04_120002_create_plugin_reports_table.php new file mode 100644 index 000000000..99f542b01 --- /dev/null +++ b/database/migrations/2026_09_04_120002_create_plugin_reports_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('plugin_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('category'); + $table->text('message'); + $table->string('status')->default('open'); + $table->text('resolution_note')->nullable(); + $table->foreignId('resolved_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('resolved_at')->nullable(); + $table->timestamps(); + + $table->index(['plugin_id', 'status']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('plugin_reports'); + } +}; diff --git a/resources/views/components/plugin-card.blade.php b/resources/views/components/plugin-card.blade.php index 9d9f0807b..1f8dda120 100644 --- a/resources/views/components/plugin-card.blade.php +++ b/resources/views/components/plugin-card.blade.php @@ -61,10 +61,23 @@ class="inline-flex items-center gap-1 rounded-full bg-indigo-100 px-2.5 py-0.5 t @endif -
- View details - - - +
+ + View details + + + + + + @if ($plugin->rating_count > 0) + + + @endif
diff --git a/resources/views/plugin-show.blade.php b/resources/views/plugin-show.blade.php index e3cbff3a0..258cfad1b 100644 --- a/resources/views/plugin-show.blade.php +++ b/resources/views/plugin-show.blade.php @@ -95,8 +95,20 @@ class="text-2xl font-bold sm:text-3xl" {{ $plugin->description }}

@endif - @if ($plugin->worksInJump()) -
+
+ @if ($plugin->rating_count > 0) + + + {{ number_format($plugin->rating_average, 1) }} + ({{ $plugin->rating_count }} {{ Str::plural('rating', $plugin->rating_count) }}) + + @endif + + @if ($plugin->worksInJump()) -
- @endif + @endif +
@@ -113,6 +125,19 @@ class="inline-flex items-center gap-1.5 rounded-full bg-indigo-100 px-3 py-1 tex {{-- Divider --}} + {{-- Session Messages --}} + @if (session('error')) +
+

{{ session('error') }}

+
+ @endif + + @if (session('success')) +
+

{{ session('success') }}

+
+ @endif +
{{-- Main content - README --}}
@@ -575,6 +600,155 @@ class="size-10 shrink-0 rounded-lg object-cover"
@endif + {{-- Rate this plugin --}} + @auth + @if (auth()->user()->canRatePlugin($plugin)) + @php $myRating = \App\Models\PluginRating::findFor($plugin, auth()->user()); @endphp +
+

+ {{ $myRating ? 'Your Rating' : 'Rate this Plugin' }} +

+
+ @csrf + +
+ @for ($i = 1; $i <= 5; $i++) + + @endfor +
+ @error('rating') +

{{ $message }}

+ @enderror +
+ @if ($myRating) +
+ @csrf + @method('DELETE') + +
+ @endif +
+ @endif + @else +
+

+ Log in + to rate this plugin. +

+
+ @endauth + + {{-- Report this plugin --}} + @if (auth()->check() && auth()->user()->canReportPlugin($plugin)) +
+ + +
+
+

+ This isn't for bugs or support requests — it's only for reporting a plugin that's + malicious or whose author is unresponsive to the NativePHP team. +

+ @if ($plugin->getIssuesUrl()) +

+ Found a bug instead? + + Report it on GitHub → + +

+ @elseif ($plugin->support_channel) +

+ Found a bug instead? Use the Support link above to contact the plugin's author. +

+ @endif +
+ +
+ @csrf +
+ + + @error('category') +

{{ $message }}

+ @enderror +
+
+ + + @error('message') +

{{ $message }}

+ @enderror +
+ +
+
+
+ @endif +
diff --git a/routes/web.php b/routes/web.php index 8d281a6e7..24ca026dd 100644 --- a/routes/web.php +++ b/routes/web.php @@ -18,6 +18,8 @@ use App\Http\Controllers\NotificationUnsubscribeController; use App\Http\Controllers\OpenCollectiveWebhookController; use App\Http\Controllers\PluginDirectoryController; +use App\Http\Controllers\PluginRatingController; +use App\Http\Controllers\PluginReportController; use App\Http\Controllers\PluginWebhookController; use App\Http\Controllers\ProductController; use App\Http\Controllers\ShowBlogController; @@ -243,6 +245,12 @@ Route::get('plugins/marketplace', PluginDirectory::class)->name('plugins.marketplace'); Route::get('plugins/{vendor}/{package}', [PluginDirectoryController::class, 'show'])->name('plugins.show'); Route::get('plugins/{vendor}/{package}/license', [PluginDirectoryController::class, 'license'])->name('plugins.license'); + + Route::middleware('auth')->group(function (): void { + Route::post('plugins/{vendor}/{package}/rating', [PluginRatingController::class, 'store'])->name('plugins.rating.store'); + Route::delete('plugins/{vendor}/{package}/rating', [PluginRatingController::class, 'destroy'])->name('plugins.rating.destroy'); + Route::post('plugins/{vendor}/{package}/report', [PluginReportController::class, 'store'])->name('plugins.report.store'); + }); }); Route::view('sponsor', 'sponsoring')->name('sponsoring'); diff --git a/tests/Feature/Filament/PluginRatingsRelationManagerTest.php b/tests/Feature/Filament/PluginRatingsRelationManagerTest.php new file mode 100644 index 000000000..e3eb3739e --- /dev/null +++ b/tests/Feature/Filament/PluginRatingsRelationManagerTest.php @@ -0,0 +1,77 @@ +admin = User::factory()->create(['email' => 'admin@test.com']); + config(['filament.users' => ['admin@test.com']]); + + $this->plugin = Plugin::factory()->approved()->free()->create(); + } + + public function test_it_lists_ratings_for_the_plugin(): void + { + PluginRating::submit($this->plugin, User::factory()->create(['email' => 'rater@example.com']), 5); + + Livewire::actingAs($this->admin) + ->test(RatingsRelationManager::class, [ + 'ownerRecord' => $this->plugin, + 'pageClass' => EditPlugin::class, + ]) + ->assertSuccessful() + ->assertSee('rater@example.com') + ->assertCountTableRecords(1); + } + + public function test_admin_can_delete_an_abusive_rating_and_the_average_recalculates(): void + { + PluginRating::submit($this->plugin, User::factory()->create(), 5); + $rating = PluginRating::submit($this->plugin, User::factory()->create(), 1); + + $this->plugin->refresh(); + $this->assertEquals(3.00, $this->plugin->rating_average); + + Livewire::actingAs($this->admin) + ->test(RatingsRelationManager::class, [ + 'ownerRecord' => $this->plugin, + 'pageClass' => EditPlugin::class, + ]) + ->callTableAction('delete', $rating); + + $this->assertDatabaseMissing('plugin_ratings', ['id' => $rating->id]); + + $this->plugin->refresh(); + $this->assertEquals(5.00, $this->plugin->rating_average); + $this->assertEquals(1, $this->plugin->rating_count); + } + + public function test_it_shows_zero_ratings_for_a_plugin_with_none(): void + { + Livewire::actingAs($this->admin) + ->test(RatingsRelationManager::class, [ + 'ownerRecord' => $this->plugin, + 'pageClass' => EditPlugin::class, + ]) + ->assertCountTableRecords(0); + } +} diff --git a/tests/Feature/Filament/PluginReportResourceTest.php b/tests/Feature/Filament/PluginReportResourceTest.php new file mode 100644 index 000000000..3579a02fb --- /dev/null +++ b/tests/Feature/Filament/PluginReportResourceTest.php @@ -0,0 +1,121 @@ +admin = User::factory()->create(['email' => 'admin@test.com']); + config(['filament.users' => ['admin@test.com']]); + } + + public function test_list_page_renders_successfully(): void + { + PluginReport::factory()->count(3)->create(); + + Livewire::actingAs($this->admin) + ->test(ListPluginReports::class) + ->assertSuccessful(); + } + + public function test_list_page_shows_plugin_and_reporter(): void + { + $plugin = Plugin::factory()->create(['name' => 'acme/super-plugin']); + $reporter = User::factory()->create(['email' => 'reporter@example.com']); + + PluginReport::factory()->create([ + 'plugin_id' => $plugin->id, + 'user_id' => $reporter->id, + 'category' => PluginReportCategory::MaliciousCode, + ]); + + Livewire::actingAs($this->admin) + ->test(ListPluginReports::class) + ->assertSuccessful() + ->assertSee('acme/super-plugin') + ->assertSee('reporter@example.com'); + } + + public function test_non_admin_cannot_view_any_reports(): void + { + $user = User::factory()->create(); + + $this->assertFalse($user->can('viewAny', PluginReport::class)); + } + + public function test_view_page_renders_successfully(): void + { + $report = PluginReport::factory()->create(); + + Livewire::actingAs($this->admin) + ->test(ViewPluginReport::class, ['record' => $report->getRouteKey()]) + ->assertSuccessful(); + } + + public function test_resolve_action_marks_the_report_resolved(): void + { + $report = PluginReport::factory()->create(); + + Livewire::actingAs($this->admin) + ->test(ListPluginReports::class) + ->callTableAction('resolve', $report, data: [ + 'resolution_note' => 'Investigated, plugin is fine.', + ]); + + $report->refresh(); + $this->assertEquals(PluginReportStatus::Resolved, $report->status); + $this->assertEquals($this->admin->id, $report->resolved_by); + $this->assertNotNull($report->resolved_at); + $this->assertEquals('Investigated, plugin is fine.', $report->resolution_note); + } + + public function test_dismiss_action_marks_the_report_dismissed(): void + { + $report = PluginReport::factory()->create(); + + Livewire::actingAs($this->admin) + ->test(ListPluginReports::class) + ->callTableAction('dismiss', $report, data: [ + 'resolution_note' => 'Not actionable.', + ]); + + $report->refresh(); + $this->assertEquals(PluginReportStatus::Dismissed, $report->status); + } + + public function test_resolve_action_is_hidden_for_already_resolved_reports(): void + { + $report = PluginReport::factory()->resolved()->create(); + + Livewire::actingAs($this->admin) + ->test(ListPluginReports::class) + ->assertTableActionHidden('resolve', $report); + } + + public function test_navigation_badge_counts_only_open_reports(): void + { + PluginReport::factory()->count(2)->create(); + PluginReport::factory()->resolved()->create(); + + $this->assertEquals('2', PluginReportResource::getNavigationBadge()); + } +} diff --git a/tests/Feature/PluginRatingTest.php b/tests/Feature/PluginRatingTest.php new file mode 100644 index 000000000..13829e213 --- /dev/null +++ b/tests/Feature/PluginRatingTest.php @@ -0,0 +1,260 @@ +approved()->free()->create(); + + $response = $this->post(route('plugins.rating.store', $plugin->routeParams()), [ + 'rating' => 5, + ]); + + $response->assertRedirect(route('customer.login')); + $this->assertDatabaseCount('plugin_ratings', 0); + } + + public function test_logged_in_user_can_rate_a_free_plugin(): void + { + $plugin = Plugin::factory()->approved()->free()->create(); + $user = User::factory()->create(); + + $response = $this->actingAs($user)->post(route('plugins.rating.store', $plugin->routeParams()), [ + 'rating' => 4, + ]); + + $response->assertRedirect(); + $this->assertDatabaseHas('plugin_ratings', [ + 'plugin_id' => $plugin->id, + 'user_id' => $user->id, + 'rating' => 4, + ]); + + $plugin->refresh(); + $this->assertEquals(4.00, $plugin->rating_average); + $this->assertEquals(1, $plugin->rating_count); + } + + public function test_user_cannot_rate_their_own_plugin(): void + { + $owner = User::factory()->create(); + $plugin = Plugin::factory()->approved()->free()->for($owner)->create(); + + $response = $this->actingAs($owner)->post(route('plugins.rating.store', $plugin->routeParams()), [ + 'rating' => 5, + ]); + + $response->assertForbidden(); + $this->assertDatabaseCount('plugin_ratings', 0); + } + + public function test_user_without_a_license_cannot_rate_a_paid_plugin(): void + { + $plugin = Plugin::factory()->approved()->paid()->create(); + $user = User::factory()->create(); + + $response = $this->actingAs($user)->post(route('plugins.rating.store', $plugin->routeParams()), [ + 'rating' => 5, + ]); + + $response->assertForbidden(); + $this->assertDatabaseCount('plugin_ratings', 0); + } + + public function test_user_with_a_license_can_rate_a_paid_plugin(): void + { + $plugin = Plugin::factory()->approved()->paid()->create(); + $user = User::factory()->create(); + + PluginLicense::factory()->for($user)->for($plugin)->create(); + + $response = $this->actingAs($user)->post(route('plugins.rating.store', $plugin->routeParams()), [ + 'rating' => 3, + ]); + + $response->assertRedirect(); + $this->assertDatabaseHas('plugin_ratings', [ + 'plugin_id' => $plugin->id, + 'user_id' => $user->id, + 'rating' => 3, + ]); + } + + public function test_rating_again_updates_the_existing_rating_instead_of_creating_a_new_one(): void + { + $plugin = Plugin::factory()->approved()->free()->create(); + $user = User::factory()->create(); + + $this->actingAs($user)->post(route('plugins.rating.store', $plugin->routeParams()), ['rating' => 2]); + $this->actingAs($user)->post(route('plugins.rating.store', $plugin->routeParams()), ['rating' => 5]); + + $this->assertDatabaseCount('plugin_ratings', 1); + $this->assertDatabaseHas('plugin_ratings', [ + 'plugin_id' => $plugin->id, + 'user_id' => $user->id, + 'rating' => 5, + ]); + + $plugin->refresh(); + $this->assertEquals(5.00, $plugin->rating_average); + $this->assertEquals(1, $plugin->rating_count); + } + + public function test_rating_is_rejected_outside_the_1_to_5_range(): void + { + $plugin = Plugin::factory()->approved()->free()->create(); + $user = User::factory()->create(); + + $response = $this->actingAs($user)->post(route('plugins.rating.store', $plugin->routeParams()), [ + 'rating' => 6, + ]); + + $response->assertSessionHasErrors('rating'); + $this->assertDatabaseCount('plugin_ratings', 0); + } + + public function test_user_can_remove_their_own_rating(): void + { + $plugin = Plugin::factory()->approved()->free()->create(); + $user = User::factory()->create(); + + $this->actingAs($user)->post(route('plugins.rating.store', $plugin->routeParams()), ['rating' => 4]); + + $response = $this->actingAs($user)->delete(route('plugins.rating.destroy', $plugin->routeParams())); + + $response->assertRedirect(); + $this->assertDatabaseCount('plugin_ratings', 0); + + $plugin->refresh(); + $this->assertNull($plugin->rating_average); + $this->assertEquals(0, $plugin->rating_count); + } + + public function test_average_rating_recalculates_across_multiple_users(): void + { + $plugin = Plugin::factory()->approved()->free()->create(); + + PluginRating::submit($plugin, User::factory()->create(), 5); + PluginRating::submit($plugin, User::factory()->create(), 3); + + $plugin->refresh(); + $this->assertEquals(4.00, $plugin->rating_average); + $this->assertEquals(2, $plugin->rating_count); + } + + public function test_plugin_page_says_nothing_about_ratings_until_there_is_one(): void + { + $plugin = Plugin::factory()->approved()->free()->create(); + + $this->get(route('plugins.show', $plugin->routeParams())) + ->assertOk() + ->assertDontSee('No ratings yet') + ->assertDontSee('out of 5 stars'); + } + + public function test_plugin_page_shows_the_average_once_the_plugin_has_a_rating(): void + { + $plugin = Plugin::factory()->approved()->free()->create(); + + PluginRating::submit($plugin, User::factory()->create(), 4); + + $this->get(route('plugins.show', $plugin->routeParams())) + ->assertOk() + ->assertSee('4.0') + ->assertSee('(1 rating)'); + } + + public function test_plugin_card_shows_the_rating_when_the_plugin_has_one(): void + { + $plugin = Plugin::factory()->approved()->free()->create(); + + PluginRating::submit($plugin, User::factory()->create(), 5); + PluginRating::submit($plugin, User::factory()->create(), 4); + + Livewire::test(PluginDirectory::class) + ->assertSee('4.5') + ->assertSee('4.5 out of 5 stars from 2 ratings'); + } + + public function test_plugin_card_hides_the_rating_when_the_plugin_has_none(): void + { + Plugin::factory()->approved()->free()->create(); + + Livewire::test(PluginDirectory::class) + ->assertDontSee('out of 5 stars'); + } + + public function test_login_link_on_the_plugin_page_carries_the_plugin_page_as_the_destination(): void + { + $plugin = Plugin::factory()->approved()->free()->create(); + + $this->get(route('plugins.show', $plugin->routeParams())) + ->assertOk() + ->assertSee(route('customer.login', [ + 'redirect' => route('plugins.show', $plugin->routeParams(), false), + ])); + } + + public function test_user_is_returned_to_the_plugin_page_after_logging_in_to_rate_it(): void + { + $plugin = Plugin::factory()->approved()->free()->create(); + $user = User::factory()->create(); + + $this->get(route('customer.login', [ + 'redirect' => route('plugins.show', $plugin->routeParams(), false), + ]))->assertOk(); + + $this->post(route('customer.login'), [ + 'email' => $user->email, + 'password' => 'password', + ])->assertRedirect(route('plugins.show', $plugin->routeParams())); + } + + public function test_login_redirect_ignores_a_destination_outside_the_site(): void + { + $user = User::factory()->create(); + + $this->get(route('customer.login', ['redirect' => 'https://evil.example.com/phish'])) + ->assertOk(); + + $this->post(route('customer.login'), [ + 'email' => $user->email, + 'password' => 'password', + ])->assertRedirect(route('dashboard')); + } + + public function test_login_redirect_ignores_a_protocol_relative_destination(): void + { + $user = User::factory()->create(); + + $this->get(route('customer.login', ['redirect' => '//evil.example.com/phish'])) + ->assertOk(); + + $this->post(route('customer.login'), [ + 'email' => $user->email, + 'password' => 'password', + ])->assertRedirect(route('dashboard')); + } +} diff --git a/tests/Feature/PluginReportTest.php b/tests/Feature/PluginReportTest.php new file mode 100644 index 000000000..a4925e8a2 --- /dev/null +++ b/tests/Feature/PluginReportTest.php @@ -0,0 +1,129 @@ +approved()->create(); + + $response = $this->post(route('plugins.report.store', $plugin->routeParams()), [ + 'category' => PluginReportCategory::MaliciousCode->value, + 'message' => 'This plugin exfiltrates data.', + ]); + + $response->assertRedirect(route('customer.login')); + $this->assertDatabaseCount('plugin_reports', 0); + } + + public function test_logged_in_user_can_report_a_plugin_and_admins_are_notified(): void + { + Notification::fake(); + + $plugin = Plugin::factory()->approved()->create(); + $user = User::factory()->create(); + + $response = $this->actingAs($user)->post(route('plugins.report.store', $plugin->routeParams()), [ + 'category' => PluginReportCategory::UnresponsiveAuthor->value, + 'message' => "I've messaged the author for weeks with no response.", + ]); + + $response->assertRedirect(); + $this->assertDatabaseHas('plugin_reports', [ + 'plugin_id' => $plugin->id, + 'user_id' => $user->id, + 'category' => PluginReportCategory::UnresponsiveAuthor->value, + 'status' => PluginReportStatus::Open->value, + ]); + + Notification::assertSentOnDemand( + PluginReported::class, + fn (PluginReported $notification, array $channels, object $notifiable): bool => $notifiable->routes['mail'] === config('mail.support_address') + && $notification->report->plugin->is($plugin) + ); + } + + public function test_user_cannot_report_their_own_plugin(): void + { + $owner = User::factory()->create(); + $plugin = Plugin::factory()->approved()->for($owner)->create(); + + $response = $this->actingAs($owner)->post(route('plugins.report.store', $plugin->routeParams()), [ + 'category' => PluginReportCategory::Other->value, + 'message' => 'Testing self-report.', + ]); + + $response->assertForbidden(); + $this->assertDatabaseCount('plugin_reports', 0); + } + + public function test_report_requires_a_valid_category_and_message(): void + { + $plugin = Plugin::factory()->approved()->create(); + $user = User::factory()->create(); + + $response = $this->actingAs($user)->post(route('plugins.report.store', $plugin->routeParams()), [ + 'category' => 'not-a-real-category', + 'message' => '', + ]); + + $response->assertSessionHasErrors(['category', 'message']); + $this->assertDatabaseCount('plugin_reports', 0); + } + + public function test_user_cannot_file_a_second_open_report_for_the_same_plugin(): void + { + $plugin = Plugin::factory()->approved()->create(); + $user = User::factory()->create(); + + PluginReport::file($plugin, $user, PluginReportCategory::MaliciousCode, 'First report.'); + + $response = $this->actingAs($user)->post(route('plugins.report.store', $plugin->routeParams()), [ + 'category' => PluginReportCategory::MaliciousCode->value, + 'message' => 'Second report while the first is still open.', + ]); + + $response->assertSessionHas('error'); + $this->assertDatabaseCount('plugin_reports', 1); + } + + public function test_user_can_file_a_new_report_once_their_previous_one_is_resolved(): void + { + $plugin = Plugin::factory()->approved()->create(); + $user = User::factory()->create(); + $admin = User::factory()->create(); + + $firstReport = PluginReport::file($plugin, $user, PluginReportCategory::MaliciousCode, 'First report.'); + $firstReport->resolve($admin); + + $response = $this->actingAs($user)->post(route('plugins.report.store', $plugin->routeParams()), [ + 'category' => PluginReportCategory::MaliciousCode->value, + 'message' => 'Second report after the first was resolved.', + ]); + + $response->assertRedirect(); + $this->assertDatabaseCount('plugin_reports', 2); + } +}