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()); + } +}