Skip to content

Commit 9e6f3e2

Browse files
simonhampclaude
andauthored
Add admin <-> developer messaging on plugins (#478)
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) <noreply@anthropic.com>
1 parent 4a3dc86 commit 9e6f3e2

11 files changed

Lines changed: 1190 additions & 332 deletions

File tree

‎app/Enums/PluginActivityType.php‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
namespace App\Enums;
44

5+
use Illuminate\Support\Str;
6+
57
enum PluginActivityType: string
68
{
79
case Submitted = 'submitted';
@@ -11,6 +13,18 @@ enum PluginActivityType: string
1113
case DescriptionUpdated = 'description_updated';
1214
case Withdrawn = 'withdrawn';
1315
case ReturnedToDraft = 'returned_to_draft';
16+
case MessageToDeveloper = 'message_to_developer';
17+
case MessageFromDeveloper = 'message_from_developer';
18+
19+
/**
20+
* Types that represent a message in the admin <-> developer conversation.
21+
*
22+
* @return array<int, self>
23+
*/
24+
public static function messageTypes(): array
25+
{
26+
return [self::MessageToDeveloper, self::MessageFromDeveloper];
27+
}
1428

1529
public function label(): string
1630
{
@@ -22,6 +36,20 @@ public function label(): string
2236
self::DescriptionUpdated => 'Description Updated',
2337
self::Withdrawn => 'Withdrawn',
2438
self::ReturnedToDraft => 'Returned to Draft',
39+
self::MessageToDeveloper => 'Message Sent',
40+
self::MessageFromDeveloper => 'Developer Reply',
41+
};
42+
}
43+
44+
/**
45+
* The same history read from the developer's side of the conversation.
46+
*/
47+
public function developerLabel(): string
48+
{
49+
return match ($this) {
50+
self::MessageToDeveloper => 'Message from NativePHP',
51+
self::MessageFromDeveloper => 'Your Reply',
52+
default => $this->label(),
2553
};
2654
}
2755

@@ -35,6 +63,23 @@ public function color(): string
3563
self::DescriptionUpdated => 'gray',
3664
self::Withdrawn => 'warning',
3765
self::ReturnedToDraft => 'warning',
66+
self::MessageToDeveloper => 'primary',
67+
self::MessageFromDeveloper => 'info',
68+
};
69+
}
70+
71+
/**
72+
* Flux badge colour, grouping the log into approvals, rejections, each side
73+
* of the conversation, and muted everyday status changes.
74+
*/
75+
public function badgeColor(): string
76+
{
77+
return match ($this) {
78+
self::Approved => 'green',
79+
self::Rejected => 'red',
80+
self::MessageToDeveloper => 'purple',
81+
self::MessageFromDeveloper => 'sky',
82+
default => 'zinc',
3883
};
3984
}
4085

@@ -48,6 +93,16 @@ public function icon(): string
4893
self::DescriptionUpdated => 'heroicon-o-pencil-square',
4994
self::Withdrawn => 'heroicon-o-arrow-uturn-left',
5095
self::ReturnedToDraft => 'heroicon-o-arrow-uturn-left',
96+
self::MessageToDeveloper => 'heroicon-o-chat-bubble-left-right',
97+
self::MessageFromDeveloper => 'heroicon-o-chat-bubble-left-ellipsis',
5198
};
5299
}
100+
101+
/**
102+
* The icon without its Blade component prefix, as Flux components expect it.
103+
*/
104+
public function iconName(): string
105+
{
106+
return Str::after($this->icon(), 'heroicon-o-');
107+
}
53108
}

‎app/Filament/Resources/PluginResource/Pages/EditPlugin.php‎

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,40 @@ protected function getHeaderActions(): array
7676
->modalHeading('Reject Plugin')
7777
->modalDescription(fn () => "Are you sure you want to reject '{$this->record->name}'?"),
7878

79+
Actions\Action::make('messageDeveloper')
80+
->label('Message Developer')
81+
->icon('heroicon-o-chat-bubble-left-right')
82+
->color('info')
83+
->form([
84+
Forms\Components\Textarea::make('message')
85+
->label('Message')
86+
->required()
87+
->rows(5)
88+
->maxLength(5000)
89+
->helperText('The developer is emailed a notification without the message contents; they must sign in to read and reply.')
90+
->placeholder('Ask a question or share feedback about this plugin...'),
91+
])
92+
->action(function (array $data): void {
93+
$this->record->messageDeveloper($data['message'], auth()->id());
94+
95+
Notification::make()
96+
->title('Message sent')
97+
->body("{$this->record->user->email} has been notified that a message is waiting.")
98+
->success()
99+
->send();
100+
})
101+
->modalHeading('Message Developer')
102+
->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.")
103+
->modalSubmitActionLabel('Send Message'),
104+
105+
Actions\Action::make('viewListing')
106+
->label('View Listing Page')
107+
->icon('heroicon-o-eye')
108+
->color('gray')
109+
->url(fn () => route('plugins.show', $this->record->routeParams()))
110+
->openUrlInNewTab()
111+
->visible(fn () => $this->record->isApproved() || $this->record->isPending()),
112+
79113
Actions\ActionGroup::make([
80114
Actions\Action::make('convertToPaid')
81115
->label('Convert to Paid')
@@ -262,14 +296,6 @@ protected function getHeaderActions(): array
262296
->success()
263297
->send();
264298
}),
265-
266-
Actions\Action::make('viewListing')
267-
->label('View Listing Page')
268-
->icon('heroicon-o-eye')
269-
->color('gray')
270-
->url(fn () => route('plugins.show', $this->record->routeParams()))
271-
->openUrlInNewTab()
272-
->visible(fn () => $this->record->isApproved() || $this->record->isPending()),
273299
])
274300
->icon('heroicon-m-ellipsis-vertical'),
275301
];

‎app/Filament/Resources/PluginResource/RelationManagers/ActivitiesRelationManager.php‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,9 @@ public function table(Table $table): Table
3535
->color('gray'),
3636

3737
Tables\Columns\TextColumn::make('note')
38-
->label('Note/Reason')
39-
->limit(50)
38+
->label('Note/Message')
39+
->limit(120)
40+
->wrap()
4041
->tooltip(fn ($record) => $record->note)
4142
->placeholder('-'),
4243

‎app/Livewire/Customer/Plugins/Show.php‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,25 @@
22

33
namespace App\Livewire\Customer\Plugins;
44

5+
use App\Enums\PluginActivityType;
56
use App\Enums\PluginTier;
67
use App\Enums\PluginType;
78
use App\Jobs\ReviewPluginRepository;
89
use App\Models\Plugin;
10+
use App\Models\PluginActivity;
911
use App\Notifications\PluginPendingReview;
1012
use App\Notifications\PluginSubmitted;
1113
use App\Services\GitHubUserService;
1214
use Flux\Flux;
15+
use Illuminate\Database\Eloquent\Collection;
1316
use Illuminate\Support\Facades\Notification;
17+
use Illuminate\Support\Facades\RateLimiter;
1418
use Illuminate\Support\Facades\Storage;
1519
use Illuminate\Validation\ValidationException;
1620
use Livewire\Attributes\Computed;
1721
use Livewire\Attributes\Layout;
1822
use Livewire\Attributes\Title;
23+
use Livewire\Attributes\Url;
1924
use Livewire\Attributes\Validate;
2025
use Livewire\Component;
2126
use Livewire\WithFileUploads;
@@ -47,18 +52,51 @@ class Show extends Component
4752

4853
public string $notes = '';
4954

55+
#[Url(as: 'tab')]
5056
public string $activeTab = 'details';
5157

5258
public string $pluginType = 'free';
5359

5460
public ?string $tier = null;
5561

62+
public string $replyMessage = '';
63+
5664
#[Computed]
5765
public function hasCompletedDeveloperOnboarding(): bool
5866
{
5967
return auth()->user()->developerAccount?->hasCompletedOnboarding() ?? false;
6068
}
6169

70+
/**
71+
* The full activity history, newest first.
72+
*
73+
* @return Collection<int, PluginActivity>
74+
*/
75+
#[Computed]
76+
public function activities(): Collection
77+
{
78+
return $this->plugin->activities()
79+
->with('causer')
80+
->orderByDesc('id')
81+
->get();
82+
}
83+
84+
/**
85+
* Developers join a conversation the admins started; they can't open one.
86+
* Drafts haven't been submitted yet, so there's nothing to discuss.
87+
*/
88+
#[Computed]
89+
public function canMessageAdmins(): bool
90+
{
91+
if ($this->plugin->isDraft()) {
92+
return false;
93+
}
94+
95+
return $this->activities->contains(
96+
fn (PluginActivity $activity): bool => $activity->type === PluginActivityType::MessageToDeveloper
97+
);
98+
}
99+
62100
public function mount(string $vendor, string $package): void
63101
{
64102
$this->plugin = Plugin::findByVendorPackageOrFail($vendor, $package);
@@ -234,6 +272,38 @@ public function withdrawFromReview(): void
234272
Flux::toast(variant: 'success', text: 'Your plugin has been withdrawn from review and returned to draft.');
235273
}
236274

275+
public function sendMessage(): void
276+
{
277+
if (! $this->canMessageAdmins) {
278+
Flux::toast(variant: 'danger', text: 'You can only reply once the Marketplace admins have messaged you about this plugin.');
279+
280+
return;
281+
}
282+
283+
$key = 'plugin-message-reply:'.auth()->id();
284+
285+
if (RateLimiter::tooManyAttempts($key, 10)) {
286+
$seconds = RateLimiter::availableIn($key);
287+
288+
$this->addError('replyMessage', "You're sending messages too quickly. Please wait {$seconds} seconds.");
289+
290+
return;
291+
}
292+
293+
$this->validate([
294+
'replyMessage' => ['required', 'string', 'max:5000'],
295+
]);
296+
297+
RateLimiter::hit($key, 60);
298+
299+
$this->plugin->messageAdmins($this->replyMessage, auth()->id());
300+
301+
$this->replyMessage = '';
302+
unset($this->activities, $this->canMessageAdmins);
303+
304+
Flux::toast(variant: 'success', text: 'Your message has been sent to the Marketplace admins.');
305+
}
306+
237307
public function returnToDraft(): void
238308
{
239309
if (! $this->plugin->isRejected()) {

‎app/Models/Plugin.php‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
use App\Enums\PriceTier;
1010
use App\Jobs\SendNewPluginNotifications;
1111
use App\Notifications\PluginApproved;
12+
use App\Notifications\PluginDeveloperReplied;
13+
use App\Notifications\PluginMessageReceived;
1214
use App\Notifications\PluginRejected;
1315
use App\Services\OgImageService;
1416
use App\Services\PluginSyncService;
@@ -24,6 +26,7 @@
2426
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
2527
use Illuminate\Database\Eloquent\Relations\HasMany;
2628
use Illuminate\Database\Eloquent\Relations\HasOne;
29+
use Illuminate\Support\Facades\Notification;
2730

2831
class Plugin extends Model
2932
{
@@ -137,6 +140,18 @@ public function activities(): HasMany
137140
return $this->hasMany(PluginActivity::class)->latest();
138141
}
139142

143+
/**
144+
* The admin <-> developer conversation, oldest message first.
145+
*
146+
* @return HasMany<PluginActivity>
147+
*/
148+
public function messages(): HasMany
149+
{
150+
return $this->hasMany(PluginActivity::class)
151+
->messages()
152+
->oldest();
153+
}
154+
140155
/**
141156
* @return BelongsTo<DeveloperAccount, Plugin>
142157
*/
@@ -719,6 +734,46 @@ public function returnToDraft(): void
719734
);
720735
}
721736

737+
/**
738+
* Send an ad-hoc message from the Marketplace admins to the plugin's developer.
739+
*
740+
* The message body is only ever surfaced in-app; the developer is emailed a
741+
* content-free nudge to log in and read it.
742+
*/
743+
public function messageDeveloper(string $message, ?int $causerId = null): PluginActivity
744+
{
745+
$activity = $this->activities()->create([
746+
'type' => PluginActivityType::MessageToDeveloper,
747+
'from_status' => null,
748+
'to_status' => $this->status->value,
749+
'note' => $message,
750+
'causer_id' => $causerId,
751+
]);
752+
753+
$this->user->notify(new PluginMessageReceived($this));
754+
755+
return $activity;
756+
}
757+
758+
/**
759+
* Record a developer's reply to the Marketplace admins and notify them by email.
760+
*/
761+
public function messageAdmins(string $message, ?int $causerId = null): PluginActivity
762+
{
763+
$activity = $this->activities()->create([
764+
'type' => PluginActivityType::MessageFromDeveloper,
765+
'from_status' => null,
766+
'to_status' => $this->status->value,
767+
'note' => $message,
768+
'causer_id' => $causerId ?? $this->user_id,
769+
]);
770+
771+
Notification::route('mail', 'support@nativephp.com')
772+
->notify(new PluginDeveloperReplied($this, $activity));
773+
774+
return $activity;
775+
}
776+
722777
public function updateDescription(string $description, int $updatedById): void
723778
{
724779
$oldDescription = $this->description;

‎app/Models/PluginActivity.php‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,38 @@
44

55
use App\Enums\PluginActivityType;
66
use App\Enums\PluginStatus;
7+
use Illuminate\Database\Eloquent\Attributes\Scope;
8+
use Illuminate\Database\Eloquent\Builder;
79
use Illuminate\Database\Eloquent\Model;
810
use Illuminate\Database\Eloquent\Relations\BelongsTo;
911

1012
class PluginActivity extends Model
1113
{
1214
protected $guarded = [];
1315

16+
/**
17+
* @param Builder<PluginActivity> $query
18+
* @return Builder<PluginActivity>
19+
*/
20+
#[Scope]
21+
protected function messages(Builder $query): Builder
22+
{
23+
return $query->whereIn('type', PluginActivityType::messageTypes());
24+
}
25+
26+
/**
27+
* How this entry's causer should be described when the plugin's owner
28+
* (`$ownerId`) is the one reading the log.
29+
*/
30+
public function causerNameFor(int $ownerId): string
31+
{
32+
return match (true) {
33+
$this->causer_id === $ownerId => 'you',
34+
$this->causer !== null => $this->causer->name,
35+
default => 'NativePHP',
36+
};
37+
}
38+
1439
/**
1540
* @return BelongsTo<Plugin, PluginActivity>
1641
*/

0 commit comments

Comments
 (0)