From e7741f51fdb77fa591e62aac238fe61a416c9fed Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Fri, 21 Aug 2026 10:16:44 +0100 Subject: [PATCH] Add admin <-> developer messaging on plugins Marketplace admins can send an ad-hoc message to a plugin's developer from the Filament edit page, independently of approving or rejecting it. Messages are stored as plugin activities so they show up in the Activity History. The developer is emailed a content-free notification and must sign in to read the message and reply. Replying is limited to submitted plugins (pending, approved or rejected) where an admin has messaged first, so developers can't open arbitrary threads. The customer plugin page gains an Activity tab showing the full history newest first, colour-coded by approval, rejection, each side of the conversation and everyday status changes. The tab is tracked in the query string so a refresh keeps your place. Also promotes "View Listing Page" out of the admin submenu to sit alongside Approve and Reject. Co-Authored-By: Claude Opus 5 (1M context) --- app/Enums/PluginActivityType.php | 55 ++ .../PluginResource/Pages/EditPlugin.php | 42 +- .../ActivitiesRelationManager.php | 5 +- app/Livewire/Customer/Plugins/Show.php | 70 ++ app/Models/Plugin.php | 55 ++ app/Models/PluginActivity.php | 25 + app/Notifications/PluginDeveloperReplied.php | 44 ++ app/Notifications/PluginMessageReceived.php | 62 ++ .../livewire/customer/plugins/show.blade.php | 700 ++++++++++-------- .../Filament/PluginMessageDeveloperTest.php | 147 ++++ .../Livewire/Customer/PluginMessagesTest.php | 317 ++++++++ 11 files changed, 1190 insertions(+), 332 deletions(-) create mode 100644 app/Notifications/PluginDeveloperReplied.php create mode 100644 app/Notifications/PluginMessageReceived.php create mode 100644 tests/Feature/Filament/PluginMessageDeveloperTest.php create mode 100644 tests/Feature/Livewire/Customer/PluginMessagesTest.php diff --git a/app/Enums/PluginActivityType.php b/app/Enums/PluginActivityType.php index 497cc15c..cf1914ed 100644 --- a/app/Enums/PluginActivityType.php +++ b/app/Enums/PluginActivityType.php @@ -2,6 +2,8 @@ namespace App\Enums; +use Illuminate\Support\Str; + enum PluginActivityType: string { case Submitted = 'submitted'; @@ -11,6 +13,18 @@ enum PluginActivityType: string case DescriptionUpdated = 'description_updated'; case Withdrawn = 'withdrawn'; case ReturnedToDraft = 'returned_to_draft'; + case MessageToDeveloper = 'message_to_developer'; + case MessageFromDeveloper = 'message_from_developer'; + + /** + * Types that represent a message in the admin <-> developer conversation. + * + * @return array + */ + public static function messageTypes(): array + { + return [self::MessageToDeveloper, self::MessageFromDeveloper]; + } public function label(): string { @@ -22,6 +36,20 @@ public function label(): string self::DescriptionUpdated => 'Description Updated', self::Withdrawn => 'Withdrawn', self::ReturnedToDraft => 'Returned to Draft', + self::MessageToDeveloper => 'Message Sent', + self::MessageFromDeveloper => 'Developer Reply', + }; + } + + /** + * The same history read from the developer's side of the conversation. + */ + public function developerLabel(): string + { + return match ($this) { + self::MessageToDeveloper => 'Message from NativePHP', + self::MessageFromDeveloper => 'Your Reply', + default => $this->label(), }; } @@ -35,6 +63,23 @@ public function color(): string self::DescriptionUpdated => 'gray', self::Withdrawn => 'warning', self::ReturnedToDraft => 'warning', + self::MessageToDeveloper => 'primary', + self::MessageFromDeveloper => 'info', + }; + } + + /** + * Flux badge colour, grouping the log into approvals, rejections, each side + * of the conversation, and muted everyday status changes. + */ + public function badgeColor(): string + { + return match ($this) { + self::Approved => 'green', + self::Rejected => 'red', + self::MessageToDeveloper => 'purple', + self::MessageFromDeveloper => 'sky', + default => 'zinc', }; } @@ -48,6 +93,16 @@ public function icon(): string self::DescriptionUpdated => 'heroicon-o-pencil-square', self::Withdrawn => 'heroicon-o-arrow-uturn-left', self::ReturnedToDraft => 'heroicon-o-arrow-uturn-left', + self::MessageToDeveloper => 'heroicon-o-chat-bubble-left-right', + self::MessageFromDeveloper => 'heroicon-o-chat-bubble-left-ellipsis', }; } + + /** + * The icon without its Blade component prefix, as Flux components expect it. + */ + public function iconName(): string + { + return Str::after($this->icon(), 'heroicon-o-'); + } } diff --git a/app/Filament/Resources/PluginResource/Pages/EditPlugin.php b/app/Filament/Resources/PluginResource/Pages/EditPlugin.php index e2aab558..7558b504 100644 --- a/app/Filament/Resources/PluginResource/Pages/EditPlugin.php +++ b/app/Filament/Resources/PluginResource/Pages/EditPlugin.php @@ -76,6 +76,40 @@ protected function getHeaderActions(): array ->modalHeading('Reject Plugin') ->modalDescription(fn () => "Are you sure you want to reject '{$this->record->name}'?"), + Actions\Action::make('messageDeveloper') + ->label('Message Developer') + ->icon('heroicon-o-chat-bubble-left-right') + ->color('info') + ->form([ + Forms\Components\Textarea::make('message') + ->label('Message') + ->required() + ->rows(5) + ->maxLength(5000) + ->helperText('The developer is emailed a notification without the message contents; they must sign in to read and reply.') + ->placeholder('Ask a question or share feedback about this plugin...'), + ]) + ->action(function (array $data): void { + $this->record->messageDeveloper($data['message'], auth()->id()); + + Notification::make() + ->title('Message sent') + ->body("{$this->record->user->email} has been notified that a message is waiting.") + ->success() + ->send(); + }) + ->modalHeading('Message Developer') + ->modalDescription(fn () => "Send a message to {$this->record->user->email} about '{$this->record->name}'. It will appear in the Activity History and they can reply from their dashboard.") + ->modalSubmitActionLabel('Send Message'), + + Actions\Action::make('viewListing') + ->label('View Listing Page') + ->icon('heroicon-o-eye') + ->color('gray') + ->url(fn () => route('plugins.show', $this->record->routeParams())) + ->openUrlInNewTab() + ->visible(fn () => $this->record->isApproved() || $this->record->isPending()), + Actions\ActionGroup::make([ Actions\Action::make('convertToPaid') ->label('Convert to Paid') @@ -262,14 +296,6 @@ protected function getHeaderActions(): array ->success() ->send(); }), - - Actions\Action::make('viewListing') - ->label('View Listing Page') - ->icon('heroicon-o-eye') - ->color('gray') - ->url(fn () => route('plugins.show', $this->record->routeParams())) - ->openUrlInNewTab() - ->visible(fn () => $this->record->isApproved() || $this->record->isPending()), ]) ->icon('heroicon-m-ellipsis-vertical'), ]; diff --git a/app/Filament/Resources/PluginResource/RelationManagers/ActivitiesRelationManager.php b/app/Filament/Resources/PluginResource/RelationManagers/ActivitiesRelationManager.php index 5119dd88..9eeb8db7 100644 --- a/app/Filament/Resources/PluginResource/RelationManagers/ActivitiesRelationManager.php +++ b/app/Filament/Resources/PluginResource/RelationManagers/ActivitiesRelationManager.php @@ -35,8 +35,9 @@ public function table(Table $table): Table ->color('gray'), Tables\Columns\TextColumn::make('note') - ->label('Note/Reason') - ->limit(50) + ->label('Note/Message') + ->limit(120) + ->wrap() ->tooltip(fn ($record) => $record->note) ->placeholder('-'), diff --git a/app/Livewire/Customer/Plugins/Show.php b/app/Livewire/Customer/Plugins/Show.php index 304ff020..0e97eb1e 100644 --- a/app/Livewire/Customer/Plugins/Show.php +++ b/app/Livewire/Customer/Plugins/Show.php @@ -2,20 +2,25 @@ namespace App\Livewire\Customer\Plugins; +use App\Enums\PluginActivityType; use App\Enums\PluginTier; use App\Enums\PluginType; use App\Jobs\ReviewPluginRepository; use App\Models\Plugin; +use App\Models\PluginActivity; use App\Notifications\PluginPendingReview; use App\Notifications\PluginSubmitted; use App\Services\GitHubUserService; use Flux\Flux; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Facades\Notification; +use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\Storage; use Illuminate\Validation\ValidationException; use Livewire\Attributes\Computed; use Livewire\Attributes\Layout; use Livewire\Attributes\Title; +use Livewire\Attributes\Url; use Livewire\Attributes\Validate; use Livewire\Component; use Livewire\WithFileUploads; @@ -47,18 +52,51 @@ class Show extends Component public string $notes = ''; + #[Url(as: 'tab')] public string $activeTab = 'details'; public string $pluginType = 'free'; public ?string $tier = null; + public string $replyMessage = ''; + #[Computed] public function hasCompletedDeveloperOnboarding(): bool { return auth()->user()->developerAccount?->hasCompletedOnboarding() ?? false; } + /** + * The full activity history, newest first. + * + * @return Collection + */ + #[Computed] + public function activities(): Collection + { + return $this->plugin->activities() + ->with('causer') + ->orderByDesc('id') + ->get(); + } + + /** + * Developers join a conversation the admins started; they can't open one. + * Drafts haven't been submitted yet, so there's nothing to discuss. + */ + #[Computed] + public function canMessageAdmins(): bool + { + if ($this->plugin->isDraft()) { + return false; + } + + return $this->activities->contains( + fn (PluginActivity $activity): bool => $activity->type === PluginActivityType::MessageToDeveloper + ); + } + public function mount(string $vendor, string $package): void { $this->plugin = Plugin::findByVendorPackageOrFail($vendor, $package); @@ -234,6 +272,38 @@ public function withdrawFromReview(): void Flux::toast(variant: 'success', text: 'Your plugin has been withdrawn from review and returned to draft.'); } + public function sendMessage(): void + { + if (! $this->canMessageAdmins) { + Flux::toast(variant: 'danger', text: 'You can only reply once the Marketplace admins have messaged you about this plugin.'); + + return; + } + + $key = 'plugin-message-reply:'.auth()->id(); + + if (RateLimiter::tooManyAttempts($key, 10)) { + $seconds = RateLimiter::availableIn($key); + + $this->addError('replyMessage', "You're sending messages too quickly. Please wait {$seconds} seconds."); + + return; + } + + $this->validate([ + 'replyMessage' => ['required', 'string', 'max:5000'], + ]); + + RateLimiter::hit($key, 60); + + $this->plugin->messageAdmins($this->replyMessage, auth()->id()); + + $this->replyMessage = ''; + unset($this->activities, $this->canMessageAdmins); + + Flux::toast(variant: 'success', text: 'Your message has been sent to the Marketplace admins.'); + } + public function returnToDraft(): void { if (! $this->plugin->isRejected()) { diff --git a/app/Models/Plugin.php b/app/Models/Plugin.php index f0026d32..d74ddd02 100644 --- a/app/Models/Plugin.php +++ b/app/Models/Plugin.php @@ -9,6 +9,8 @@ use App\Enums\PriceTier; use App\Jobs\SendNewPluginNotifications; use App\Notifications\PluginApproved; +use App\Notifications\PluginDeveloperReplied; +use App\Notifications\PluginMessageReceived; use App\Notifications\PluginRejected; use App\Services\OgImageService; use App\Services\PluginSyncService; @@ -22,6 +24,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; +use Illuminate\Support\Facades\Notification; class Plugin extends Model { @@ -135,6 +138,18 @@ public function activities(): HasMany return $this->hasMany(PluginActivity::class)->latest(); } + /** + * The admin <-> developer conversation, oldest message first. + * + * @return HasMany + */ + public function messages(): HasMany + { + return $this->hasMany(PluginActivity::class) + ->messages() + ->oldest(); + } + /** * @return BelongsTo */ @@ -691,6 +706,46 @@ public function returnToDraft(): void ); } + /** + * Send an ad-hoc message from the Marketplace admins to the plugin's developer. + * + * The message body is only ever surfaced in-app; the developer is emailed a + * content-free nudge to log in and read it. + */ + public function messageDeveloper(string $message, ?int $causerId = null): PluginActivity + { + $activity = $this->activities()->create([ + 'type' => PluginActivityType::MessageToDeveloper, + 'from_status' => null, + 'to_status' => $this->status->value, + 'note' => $message, + 'causer_id' => $causerId, + ]); + + $this->user->notify(new PluginMessageReceived($this)); + + return $activity; + } + + /** + * Record a developer's reply to the Marketplace admins and notify them by email. + */ + public function messageAdmins(string $message, ?int $causerId = null): PluginActivity + { + $activity = $this->activities()->create([ + 'type' => PluginActivityType::MessageFromDeveloper, + 'from_status' => null, + 'to_status' => $this->status->value, + 'note' => $message, + 'causer_id' => $causerId ?? $this->user_id, + ]); + + Notification::route('mail', 'support@nativephp.com') + ->notify(new PluginDeveloperReplied($this, $activity)); + + return $activity; + } + public function updateDescription(string $description, int $updatedById): void { $oldDescription = $this->description; diff --git a/app/Models/PluginActivity.php b/app/Models/PluginActivity.php index ee6be4b9..08518145 100644 --- a/app/Models/PluginActivity.php +++ b/app/Models/PluginActivity.php @@ -4,6 +4,8 @@ use App\Enums\PluginActivityType; use App\Enums\PluginStatus; +use Illuminate\Database\Eloquent\Attributes\Scope; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -11,6 +13,29 @@ class PluginActivity extends Model { protected $guarded = []; + /** + * @param Builder $query + * @return Builder + */ + #[Scope] + protected function messages(Builder $query): Builder + { + return $query->whereIn('type', PluginActivityType::messageTypes()); + } + + /** + * How this entry's causer should be described when the plugin's owner + * (`$ownerId`) is the one reading the log. + */ + public function causerNameFor(int $ownerId): string + { + return match (true) { + $this->causer_id === $ownerId => 'you', + $this->causer !== null => $this->causer->name, + default => 'NativePHP', + }; + } + /** * @return BelongsTo */ diff --git a/app/Notifications/PluginDeveloperReplied.php b/app/Notifications/PluginDeveloperReplied.php new file mode 100644 index 00000000..a43e60d8 --- /dev/null +++ b/app/Notifications/PluginDeveloperReplied.php @@ -0,0 +1,44 @@ + + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + public function toMail(object $notifiable): MailMessage + { + $plugin = $this->plugin->loadMissing('user'); + + return (new MailMessage) + ->subject('New plugin reply: '.$plugin->name) + ->greeting('A developer has replied about their plugin!') + ->line("**Plugin:** {$plugin->name}") + ->line('**From:** '.($plugin->user?->name ?? 'Unknown')) + ->line('**Reply:**') + ->line(Str::limit((string) $this->activity->note, 500)) + ->action('View Plugin', PluginResource::getUrl('edit', ['record' => $plugin])); + } +} diff --git a/app/Notifications/PluginMessageReceived.php b/app/Notifications/PluginMessageReceived.php new file mode 100644 index 00000000..69791066 --- /dev/null +++ b/app/Notifications/PluginMessageReceived.php @@ -0,0 +1,62 @@ + + */ + public function via(object $notifiable): array + { + return ['mail', 'database']; + } + + public function toMail(object $notifiable): MailMessage + { + return (new MailMessage) + ->subject('New message about your plugin') + ->greeting('Hello,') + ->line("The NativePHP team has sent you a message about your plugin **{$this->plugin->name}**.") + ->line('Please sign in to read it and reply.') + ->action('View Message', $this->actionUrl()) + ->line('*Please do not reply to this email — responses must be sent from your plugin\'s page.*'); + } + + /** + * @return array + */ + public function toArray(object $notifiable): array + { + return [ + 'title' => 'New message about your plugin', + 'body' => "The NativePHP team has sent you a message about {$this->plugin->name}. Sign in to read it and reply.", + 'plugin_id' => $this->plugin->id, + 'plugin_name' => $this->plugin->name, + 'action_url' => $this->actionUrl(), + 'action_label' => 'View Message', + ]; + } + + protected function actionUrl(): string + { + return route('customer.plugins.show', [...$this->plugin->routeParams(), 'tab' => 'activity']); + } +} diff --git a/resources/views/livewire/customer/plugins/show.blade.php b/resources/views/livewire/customer/plugins/show.blade.php index 636a3275..1f7214c5 100644 --- a/resources/views/livewire/customer/plugins/show.blade.php +++ b/resources/views/livewire/customer/plugins/show.blade.php @@ -250,15 +250,18 @@ @endif - {{-- Editable fields for Draft plugins (with tabs) --}} - @if ($plugin->isDraft()) - - - Details + {{-- Plugin details, submission and activity --}} + + + Details + @if ($plugin->isDraft()) Submit for Review - + @endif + Activity + - + + @if ($plugin->isDraft()) {{-- GitHub Repo --}} @@ -483,8 +486,335 @@ class="block text-sm text-gray-500 file:mr-4 file:rounded-md file:border-0 file: Save Changes - + @elseif ($plugin->isApproved()) + {{-- Editable fields for Approved plugins (no tabs) --}} + + {{-- GitHub Repo --}} + + +
+
+ + {{ $plugin->name }} +
+ +
+
+
+ + {{-- Read-only Type & Tier --}} + +
+
+ Type + + @if ($plugin->isPaid() && $plugin->tier) + Paid — {{ $plugin->tier->label() }} + @elseif ($plugin->isPaid()) + Paid + @else + Free + @endif + +
+
+
+ +
+ {{-- Display Name --}} + + Name (optional) + A display name for your plugin. If not set, your Composer package name will be used. + +
+ + Maximum 250 characters +
+
+ + {{-- Description --}} + + Description + Describe what your plugin does. This will be displayed in the plugin directory. + +
+ + @error('description') + {{ $message }} + @enderror + Maximum 1000 characters +
+
+ + {{-- Icon --}} + + Icon + Choose a gradient and icon, or upload your own logo. + +
+ {{-- Current Icon Preview --}} + @if ($plugin->hasCustomIcon()) +
+ @if ($plugin->hasLogo()) + {{ $plugin->name }} logo + @elseif ($plugin->hasGradientIcon()) +
+ +
+ @endif + Remove icon +
+ @endif + + {{-- Gradient Icon Picker --}} +
+
+
+ +
+ @foreach (\App\Models\Plugin::gradientPresets() as $key => $classes) + + @endforeach +
+ @error('iconGradient') + {{ $message }} + @enderror +
+ + + @error('iconName') + {{ $message }} + @enderror + + Save Icon +
+ + + +
+ + {{-- Custom Logo Upload --}} +
+
+
+ +
+ + Upload +
+ @error('logo') + {{ $message }} + @enderror + PNG, JPG, SVG, or WebP. Max 1MB. Recommended: 256x256 pixels, square. +
+
+ + + +
+
+
+ + {{-- Support Channel --}} + + Support + How can users get support for your plugin? Provide an email address or a URL. + +
+ + @error('supportChannel') + {{ $message }} + @enderror +
+
+ + {{-- Save Button --}} +
+ Save Changes +
+
+ @else + {{-- Read-only display for Pending/Rejected --}} + +
+
+ @if ($plugin->hasLogo()) + {{ $plugin->name }} logo + @elseif ($plugin->hasGradientIcon()) +
+ +
+ @else +
+ +
+ @endif +
+ {{ $plugin->display_name ?? $plugin->name }} + @if ($plugin->description) + {{ $plugin->description }} + @else + No description provided + @endif +
+
+ @if ($plugin->isPaid() && $plugin->tier) + + {{ $plugin->tier->label() }} + + @elseif ($plugin->isPaid()) + + Paid + + @else + + Free + + @endif +
+ + + +
+ {{-- Author --}} +
+
Author
+
+ {{ $plugin->user->display_name }} +
+
+ {{-- Version --}} +
+
Version
+
+ @if ($plugin->latest_version) + + {{ $plugin->latest_version }} + + + @elseif ($plugin->review_checks['release_version'] ?? null) + + {{ $plugin->review_checks['release_version'] }} + + + @else + + @endif +
+
+ + {{-- License --}} +
+
License
+
+ @if ($plugin->getLicense()) + + {{ $plugin->getLicense() }} + + + @else + + @endif +
+
+ + {{-- iOS Version --}} +
+
Min iOS
+
+ {{ $plugin->ios_version ?? ($plugin->review_checks['ios_min_version'] ?? '—') }} +
+
+ + {{-- Android Version --}} +
+
Min Android
+
+ {{ $plugin->android_version ?? ($plugin->review_checks['android_min_version'] ?? '—') }} +
+
+ + {{-- Support Channel --}} +
+
Support Channel
+
+ @if ($plugin->support_channel) + @if (filter_var($plugin->support_channel, FILTER_VALIDATE_URL)) + + {{ $plugin->support_channel }} + + + @elseif (filter_var($plugin->support_channel, FILTER_VALIDATE_EMAIL)) + + {{ $plugin->support_channel }} + + + @else + {{ $plugin->support_channel }} + @endif + @else + Not set + @endif +
+
+ + {{-- Repository --}} + +
+
+ + @if ($plugin->notes) + + Submission Notes + {{ $plugin->notes }} + + @endif + @endif +
+ + @if ($plugin->isDraft())
{{-- Plugin Summary --}} @@ -654,333 +984,59 @@ class="block text-sm text-gray-500 file:mr-4 file:rounded-md file:border-0 file:
-
- @elseif ($plugin->isApproved()) - {{-- Editable fields for Approved plugins (no tabs) --}} - - {{-- GitHub Repo --}} - - -
-
- - {{ $plugin->name }} -
- -
-
-
- - {{-- Read-only Type & Tier --}} - -
-
- Type - - @if ($plugin->isPaid() && $plugin->tier) - Paid — {{ $plugin->tier->label() }} - @elseif ($plugin->isPaid()) - Paid - @else - Free - @endif - -
-
-
- -
- {{-- Display Name --}} - - Name (optional) - A display name for your plugin. If not set, your Composer package name will be used. - -
- - Maximum 250 characters -
-
- - {{-- Description --}} - - Description - Describe what your plugin does. This will be displayed in the plugin directory. + @endif -
+ + @if ($this->canMessageAdmins) + - @error('description') + @error('replyMessage') {{ $message }} @enderror - Maximum 1000 characters -
-
- - {{-- Icon --}} - - Icon - Choose a gradient and icon, or upload your own logo. - -
- {{-- Current Icon Preview --}} - @if ($plugin->hasCustomIcon()) -
- @if ($plugin->hasLogo()) - {{ $plugin->name }} logo - @elseif ($plugin->hasGradientIcon()) -
- -
- @endif - Remove icon -
- @endif - {{-- Gradient Icon Picker --}} -
-
-
- -
- @foreach (\App\Models\Plugin::gradientPresets() as $key => $classes) - - @endforeach -
- @error('iconGradient') - {{ $message }} - @enderror -
- - - @error('iconName') - {{ $message }} - @enderror - - Save Icon -
- - - +
+ ⌘/Ctrl + Enter to send + Send Message
+ + @endif - {{-- Custom Logo Upload --}} -
-
-
- -
- - Upload -
- @error('logo') - {{ $message }} - @enderror - PNG, JPG, SVG, or WebP. Max 1MB. Recommended: 256x256 pixels, square. -
+
    + @foreach ($this->activities as $activity) +
  • +
    + + {{ $activity->type->developerLabel() }} +
    - - -
-
- - - {{-- Support Channel --}} - - Support - How can users get support for your plugin? Provide an email address or a URL. - -
- - @error('supportChannel') - {{ $message }} - @enderror -
-
- - {{-- Save Button --}} -
- Save Changes -
- - @else - {{-- Read-only display for Pending/Rejected --}} - -
-
- @if ($plugin->hasLogo()) - {{ $plugin->name }} logo - @elseif ($plugin->hasGradientIcon()) -
- -
- @else -
- -
- @endif -
- {{ $plugin->display_name ?? $plugin->name }} - @if ($plugin->description) - {{ $plugin->description }} - @else - No description provided - @endif -
-
- @if ($plugin->isPaid() && $plugin->tier) - - {{ $plugin->tier->label() }} - - @elseif ($plugin->isPaid()) - - Paid - - @else - - Free - - @endif -
- - - -
- {{-- Author --}} -
-
Author
-
- {{ $plugin->user->display_name }} -
-
- - {{-- Version --}} -
-
Version
-
- @if ($plugin->latest_version) - - {{ $plugin->latest_version }} - - - @elseif ($plugin->review_checks['release_version'] ?? null) - - {{ $plugin->review_checks['release_version'] }} - - - @else - - @endif -
-
- - {{-- License --}} -
-
License
-
- @if ($plugin->getLicense()) - - {{ $plugin->getLicense() }} - - - @else - - @endif -
-
- - {{-- iOS Version --}} -
-
Min iOS
-
- {{ $plugin->ios_version ?? ($plugin->review_checks['ios_min_version'] ?? '—') }} -
-
- - {{-- Android Version --}} -
-
Min Android
-
- {{ $plugin->android_version ?? ($plugin->review_checks['android_min_version'] ?? '—') }} -
-
- - {{-- Support Channel --}} -
-
Support Channel
-
- @if ($plugin->support_channel) - @if (filter_var($plugin->support_channel, FILTER_VALIDATE_URL)) - - {{ $plugin->support_channel }} - - - @elseif (filter_var($plugin->support_channel, FILTER_VALIDATE_EMAIL)) - - {{ $plugin->support_channel }} - - - @else - {{ $plugin->support_channel }} +
+ @if ($activity->from_status && $activity->from_status !== $activity->to_status) +

+ {{ $activity->from_status->label() }} → {{ $activity->to_status->label() }} +

@endif - @else - Not set - @endif -
-
- {{-- Repository --}} - -
-
+ @if ($activity->note) +

{{ $activity->note }}

+ @endif - @if ($plugin->notes) - - Submission Notes - {{ $plugin->notes }} - - @endif - @endif +

+ {{ $activity->created_at->format('d M Y, H:i') }} · by {{ $activity->causerNameFor($plugin->user_id) }} +

+
+ + @endforeach + + +
diff --git a/tests/Feature/Filament/PluginMessageDeveloperTest.php b/tests/Feature/Filament/PluginMessageDeveloperTest.php new file mode 100644 index 00000000..e571284d --- /dev/null +++ b/tests/Feature/Filament/PluginMessageDeveloperTest.php @@ -0,0 +1,147 @@ +admin = User::factory()->create(['email' => 'admin@test.com']); + config(['filament.users' => ['admin@test.com']]); + } + + public function test_message_developer_action_logs_activity_and_notifies_developer(): void + { + Notification::fake(); + + $developer = User::factory()->create(); + $plugin = Plugin::factory()->pending()->for($developer)->create(); + + Livewire::actingAs($this->admin) + ->test(EditPlugin::class, ['record' => $plugin->getRouteKey()]) + ->callAction('messageDeveloper', ['message' => 'Could you add a README example?']) + ->assertHasNoActionErrors() + ->assertNotified(); + + $activity = $plugin->activities()->first(); + + $this->assertSame(PluginActivityType::MessageToDeveloper, $activity->type); + $this->assertSame('Could you add a README example?', $activity->note); + $this->assertSame($this->admin->id, $activity->causer_id); + $this->assertNull($activity->from_status); + $this->assertSame($plugin->status, $activity->to_status); + + Notification::assertSentTo( + $developer, + PluginMessageReceived::class, + fn (PluginMessageReceived $notification) => $notification->plugin->is($plugin) + ); + } + + public function test_message_developer_action_does_not_change_plugin_status(): void + { + Notification::fake(); + + $plugin = Plugin::factory()->pending()->create(); + + Livewire::actingAs($this->admin) + ->test(EditPlugin::class, ['record' => $plugin->getRouteKey()]) + ->callAction('messageDeveloper', ['message' => 'Just checking in.']); + + $this->assertTrue($plugin->fresh()->isPending()); + $this->assertNull($plugin->fresh()->rejection_reason); + $this->assertNull($plugin->fresh()->approved_at); + } + + public function test_message_developer_action_requires_a_message(): void + { + Notification::fake(); + + $plugin = Plugin::factory()->pending()->create(); + + Livewire::actingAs($this->admin) + ->test(EditPlugin::class, ['record' => $plugin->getRouteKey()]) + ->callAction('messageDeveloper', ['message' => '']) + ->assertHasActionErrors(['message' => 'required']); + + $this->assertSame(0, $plugin->activities()->count()); + Notification::assertNothingSent(); + } + + public function test_message_developer_action_is_available_for_every_status(): void + { + foreach (['draft', 'pending', 'approved', 'rejected'] as $state) { + $plugin = Plugin::factory()->{$state}()->create(); + + Livewire::actingAs($this->admin) + ->test(EditPlugin::class, ['record' => $plugin->getRouteKey()]) + ->assertActionVisible('messageDeveloper'); + } + } + + public function test_developer_email_does_not_reveal_the_message_contents(): void + { + $developer = User::factory()->create(); + $plugin = Plugin::factory()->pending()->for($developer)->create(); + + $plugin->messageDeveloper('Top secret reviewer feedback', $this->admin->id); + + $mail = (new PluginMessageReceived($plugin))->toMail($developer); + $rendered = (string) $mail->render(); + + $this->assertStringNotContainsString('Top secret reviewer feedback', $rendered); + $this->assertStringContainsString('Please sign in to read it and reply.', $rendered); + $this->assertStringContainsString( + route('customer.plugins.show', $plugin->routeParams()), + $rendered + ); + } + + public function test_view_listing_action_is_a_top_level_header_action(): void + { + $plugin = Plugin::factory()->approved()->create(); + + Livewire::actingAs($this->admin) + ->test(EditPlugin::class, ['record' => $plugin->getRouteKey()]) + ->assertActionVisible('viewListing'); + + $actions = Livewire::actingAs($this->admin) + ->test(EditPlugin::class, ['record' => $plugin->getRouteKey()]) + ->instance() + ->getCachedHeaderActions(); + + $topLevelNames = collect($actions) + ->filter(fn ($action) => $action instanceof Action) + ->map(fn (Action $action) => $action->getName()) + ->all(); + + $this->assertContains('viewListing', $topLevelNames); + $this->assertContains('messageDeveloper', $topLevelNames); + } + + public function test_view_listing_action_is_hidden_for_draft_plugins(): void + { + $plugin = Plugin::factory()->draft()->create(); + + Livewire::actingAs($this->admin) + ->test(EditPlugin::class, ['record' => $plugin->getRouteKey()]) + ->assertActionHidden('viewListing'); + } +} diff --git a/tests/Feature/Livewire/Customer/PluginMessagesTest.php b/tests/Feature/Livewire/Customer/PluginMessagesTest.php new file mode 100644 index 00000000..5a7e42b2 --- /dev/null +++ b/tests/Feature/Livewire/Customer/PluginMessagesTest.php @@ -0,0 +1,317 @@ +developer = User::factory()->create(); + $this->reviewer = User::factory()->create(['name' => 'Reviewer Rita']); + + RateLimiter::clear('plugin-message-reply:'.$this->developer->id); + } + + private function pluginWithMessage(string $message = 'Please add iOS support notes.'): Plugin + { + Notification::fake(); + + $plugin = Plugin::factory()->pending()->for($this->developer)->create(); + $plugin->messageDeveloper($message, $this->reviewer->id); + + return $plugin->fresh(); + } + + private function testable(Plugin $plugin): Testable + { + return Livewire::actingAs($this->developer)->test(Show::class, [ + 'vendor' => $plugin->routeParams()['vendor'], + 'package' => $plugin->routeParams()['package'], + ]); + } + + public function test_activity_tab_shows_reviewer_message_and_its_author(): void + { + $plugin = $this->pluginWithMessage('Please add iOS support notes.'); + + $this->testable($plugin) + ->assertSee('Activity') + ->assertSee('Please add iOS support notes.') + ->assertSee('Message from NativePHP') + ->assertSee('Reviewer Rita'); + } + + public function test_activity_tab_lists_the_full_history_newest_first(): void + { + Notification::fake(); + + $plugin = Plugin::factory()->draft()->for($this->developer)->create(); + $plugin->submit(); + $plugin->messageDeveloper('One tweak needed before approval.', $this->reviewer->id); + $plugin->refresh(); + $plugin->messageAdmins('Tweaked and pushed.', $this->developer->id); + + $this->testable($plugin) + ->assertSeeInOrder([ + 'Tweaked and pushed.', + 'One tweak needed before approval.', + ]) + ->assertSee('Your Reply') + ->assertSee('Message from NativePHP') + ->assertSee('Submitted') + ->assertSee('Pending Review'); + } + + public function test_status_changes_appear_in_the_activity_tab_without_any_messages(): void + { + Notification::fake(); + + $plugin = Plugin::factory()->draft()->for($this->developer)->create(); + $plugin->submit(); + $plugin->refresh(); + $plugin->withdraw(); + + $this->testable($plugin) + ->assertSee('Withdrawn') + ->assertSee('Submitted') + ->assertSee('by you'); + } + + public function test_message_form_is_hidden_until_the_admins_send_a_message(): void + { + $plugin = Plugin::factory()->pending()->for($this->developer)->create(); + + $this->testable($plugin) + ->assertSee('Activity') + ->assertDontSee('Send a message to Marketplace admins'); + } + + public function test_message_form_is_hidden_on_drafts_even_after_an_admin_message(): void + { + Notification::fake(); + + $plugin = Plugin::factory()->draft()->for($this->developer)->create(); + $plugin->messageDeveloper('Heads up before you submit.', $this->reviewer->id); + $plugin->refresh(); + + $this->testable($plugin) + ->assertSee('Heads up before you submit.') + ->assertDontSee('Send a message to Marketplace admins'); + } + + public function test_draft_plugins_reject_developer_messages(): void + { + Notification::fake(); + + $plugin = Plugin::factory()->draft()->for($this->developer)->create(); + $plugin->messageDeveloper('Heads up before you submit.', $this->reviewer->id); + $plugin->refresh(); + + $this->testable($plugin) + ->set('replyMessage', 'Can I ask something first?') + ->call('sendMessage'); + + $this->assertSame(0, $plugin->activities() + ->where('type', PluginActivityType::MessageFromDeveloper) + ->count()); + + Notification::assertSentOnDemandTimes(PluginDeveloperReplied::class, 0); + } + + /** + * @return array + */ + public static function messageableStatuses(): array + { + return [ + 'pending' => ['pending'], + 'approved' => ['approved'], + 'rejected' => ['rejected'], + ]; + } + + #[DataProvider('messageableStatuses')] + public function test_developer_can_message_admins_on_submitted_plugins(string $state): void + { + Notification::fake(); + + $plugin = Plugin::factory()->{$state}()->for($this->developer)->create(); + $plugin->messageDeveloper('A question about your plugin.', $this->reviewer->id); + $plugin->refresh(); + + $this->testable($plugin) + ->assertSee('Send a message to Marketplace admins') + ->set('replyMessage', 'Here is my answer.') + ->call('sendMessage') + ->assertHasNoErrors(); + + $this->assertSame(2, $plugin->messages()->count()); + } + + public function test_tab_query_parameter_opens_the_activity_tab(): void + { + $plugin = $this->pluginWithMessage(); + + Livewire::actingAs($this->developer) + ->withQueryParams(['tab' => 'activity']) + ->test(Show::class, [ + 'vendor' => $plugin->routeParams()['vendor'], + 'package' => $plugin->routeParams()['package'], + ]) + ->assertSet('activeTab', 'activity'); + } + + public function test_every_activity_type_renders_with_a_badge_and_icon(): void + { + Notification::fake(); + + $plugin = Plugin::factory()->pending()->for($this->developer)->create(); + + foreach (PluginActivityType::cases() as $type) { + $plugin->activities()->create([ + 'type' => $type, + 'from_status' => PluginStatus::Draft, + 'to_status' => PluginStatus::Pending, + 'note' => "Note for {$type->value}", + 'causer_id' => $this->reviewer->id, + ]); + } + + $component = $this->testable($plugin); + + foreach (PluginActivityType::cases() as $type) { + $component + ->assertSee($type->developerLabel()) + ->assertSee("Note for {$type->value}"); + } + } + + public function test_details_is_the_default_tab(): void + { + $plugin = $this->pluginWithMessage(); + + $this->testable($plugin)->assertSet('activeTab', 'details'); + } + + public function test_developer_can_reply_and_the_team_is_notified(): void + { + $plugin = $this->pluginWithMessage(); + + Notification::fake(); + + $this->testable($plugin) + ->set('replyMessage', 'Sure — added in v1.2.0.') + ->call('sendMessage') + ->assertHasNoErrors() + ->assertSet('replyMessage', ''); + + $reply = $plugin->messages()->latest('id')->first(); + + $this->assertSame(PluginActivityType::MessageFromDeveloper, $reply->type); + $this->assertSame('Sure — added in v1.2.0.', $reply->note); + $this->assertSame($this->developer->id, $reply->causer_id); + $this->assertSame($plugin->status, $reply->to_status); + + Notification::assertSentOnDemand( + PluginDeveloperReplied::class, + fn (PluginDeveloperReplied $notification, array $channels, object $notifiable) => $notifiable->routes['mail'] === 'support@nativephp.com' + && $notification->plugin->is($plugin) + && $notification->activity->is($reply) + ); + } + + public function test_reply_appears_in_the_thread_after_sending(): void + { + $plugin = $this->pluginWithMessage(); + + Notification::fake(); + + $this->testable($plugin) + ->set('replyMessage', 'Thanks for the review!') + ->call('sendMessage') + ->assertSee('Thanks for the review!'); + } + + public function test_reply_is_required(): void + { + $plugin = $this->pluginWithMessage(); + + Notification::fake(); + + $this->testable($plugin) + ->set('replyMessage', '') + ->call('sendMessage') + ->assertHasErrors(['replyMessage' => 'required']); + + $this->assertSame(1, $plugin->messages()->count()); + Notification::assertNothingSent(); + } + + public function test_reply_is_rejected_when_there_is_no_conversation(): void + { + $plugin = Plugin::factory()->pending()->for($this->developer)->create(); + + Notification::fake(); + + $this->testable($plugin) + ->set('replyMessage', 'Hello?') + ->call('sendMessage'); + + $this->assertSame(0, $plugin->messages()->count()); + Notification::assertNothingSent(); + } + + public function test_messages_are_only_visible_to_the_plugin_owner(): void + { + $plugin = $this->pluginWithMessage('Confidential review note.'); + + $intruder = User::factory()->create(); + + Livewire::actingAs($intruder) + ->test(Show::class, [ + 'vendor' => $plugin->routeParams()['vendor'], + 'package' => $plugin->routeParams()['package'], + ]) + ->assertForbidden(); + } + + public function test_replies_are_rate_limited(): void + { + $plugin = $this->pluginWithMessage(); + + Notification::fake(); + + $component = $this->testable($plugin); + + for ($i = 0; $i < 10; $i++) { + $component->set('replyMessage', "Reply {$i}")->call('sendMessage'); + } + + $component->set('replyMessage', 'One too many')->call('sendMessage') + ->assertHasErrors('replyMessage'); + + $this->assertSame(11, $plugin->messages()->count()); + } +}