Skip to content

Commit 8d4eb87

Browse files
simonhampclaude
andcommitted
Add ticket reopen, system messages, reply rate limiting, and pinned notes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent c7af857 commit 8d4eb87

File tree

11 files changed

+405
-36
lines changed

11 files changed

+405
-36
lines changed

app/Filament/Resources/SupportTicketResource/Widgets/TicketRepliesWidget.php

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

33
namespace App\Filament\Resources\SupportTicketResource\Widgets;
44

5+
use App\Models\SupportTicket\Reply;
56
use App\Notifications\SupportTicketReplied;
67
use Filament\Widgets\Widget;
78
use Illuminate\Database\Eloquent\Model;
@@ -42,4 +43,22 @@ public function sendReply(): void
4243
$this->newMessage = '';
4344
$this->isNote = false;
4445
}
46+
47+
public function togglePin(int $replyId): void
48+
{
49+
$reply = Reply::where('support_ticket_id', $this->record->id)
50+
->where('id', $replyId)
51+
->where('note', true)
52+
->firstOrFail();
53+
54+
if ($reply->pinned) {
55+
$reply->update(['pinned' => false]);
56+
} else {
57+
Reply::where('support_ticket_id', $this->record->id)
58+
->where('pinned', true)
59+
->update(['pinned' => false]);
60+
61+
$reply->update(['pinned' => true]);
62+
}
63+
}
4564
}

app/Livewire/Customer/Support/Show.php

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use App\SupportTicket\Status;
88
use Illuminate\Contracts\View\View;
99
use Illuminate\Support\Facades\Notification;
10+
use Illuminate\Support\Facades\RateLimiter;
1011
use Livewire\Attributes\Layout;
1112
use Livewire\Attributes\Title;
1213
use Livewire\Component;
@@ -33,10 +34,22 @@ public function reply(): void
3334
{
3435
$this->authorize('reply', $this->supportTicket);
3536

37+
$key = 'support-reply:'.auth()->id();
38+
39+
if (RateLimiter::tooManyAttempts($key, 10)) {
40+
$seconds = RateLimiter::availableIn($key);
41+
42+
$this->addError('replyMessage', "You're sending messages too quickly. Please wait {$seconds} seconds.");
43+
44+
return;
45+
}
46+
3647
$this->validate([
3748
'replyMessage' => ['required', 'string', 'max:5000'],
3849
]);
3950

51+
RateLimiter::hit($key, 60);
52+
4053
$reply = $this->supportTicket->replies()->create([
4154
'user_id' => auth()->id(),
4255
'message' => $this->replyMessage,
@@ -48,8 +61,6 @@ public function reply(): void
4861

4962
$this->replyMessage = '';
5063
$this->supportTicket->load(['user', 'replies.user']);
51-
52-
session()->flash('success', 'Your reply has been sent.');
5364
}
5465

5566
public function closeTicket(): void
@@ -60,7 +71,30 @@ public function closeTicket(): void
6071
'status' => Status::CLOSED,
6172
]);
6273

63-
session()->flash('success', __('account.support_ticket.close_ticket.success'));
74+
$this->supportTicket->replies()->create([
75+
'user_id' => null,
76+
'message' => auth()->user()->name.' closed this ticket.',
77+
'note' => false,
78+
]);
79+
80+
$this->supportTicket->load(['user', 'replies.user']);
81+
}
82+
83+
public function reopenTicket(): void
84+
{
85+
$this->authorize('reopenTicket', $this->supportTicket);
86+
87+
$this->supportTicket->update([
88+
'status' => Status::OPEN,
89+
]);
90+
91+
$this->supportTicket->replies()->create([
92+
'user_id' => null,
93+
'message' => auth()->user()->name.' reopened this ticket.',
94+
'note' => false,
95+
]);
96+
97+
$this->supportTicket->load(['user', 'replies.user']);
6498
}
6599

66100
public function render(): View

app/Models/SupportTicket.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use Illuminate\Database\Eloquent\Model;
99
use Illuminate\Database\Eloquent\Relations\BelongsTo;
1010
use Illuminate\Database\Eloquent\Relations\HasMany;
11+
use Illuminate\Database\Eloquent\Relations\HasOne;
1112
use Illuminate\Database\Eloquent\SoftDeletes;
1213

1314
class SupportTicket extends Model
@@ -54,6 +55,14 @@ public function replies(): HasMany
5455
->orderBy('created_at', 'desc');
5556
}
5657

58+
public function pinnedNote(): HasOne
59+
{
60+
return $this->hasOne(Reply::class)
61+
->where('note', true)
62+
->where('pinned', true)
63+
->latestOfMany();
64+
}
65+
5766
public function user(): BelongsTo
5867
{
5968
return $this->belongsTo(User::class);

app/Models/SupportTicket/Reply.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,13 @@ class Reply extends Model
2020
'message',
2121
'attachments',
2222
'note',
23+
'pinned',
2324
];
2425

2526
protected $casts = [
2627
'attachments' => 'array',
2728
'note' => 'boolean',
29+
'pinned' => 'boolean',
2830
];
2931

3032
public function isFromAdmin(): Attribute

app/Policies/SupportTicketPolicy.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@ public function closeTicket(User $user, SupportTicket $supportTicket): bool
2020
return $supportTicket->user_id === $user->id;
2121
}
2222

23+
public function reopenTicket(User $user, SupportTicket $supportTicket): bool
24+
{
25+
return $supportTicket->user_id === $user->id
26+
&& $supportTicket->status === Status::CLOSED;
27+
}
28+
2329
/**
2430
* Determine whether the user can view any models.
2531
*/
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
use Illuminate\Database\Migrations\Migration;
4+
use Illuminate\Database\Schema\Blueprint;
5+
use Illuminate\Support\Facades\Schema;
6+
7+
return new class extends Migration
8+
{
9+
/**
10+
* Run the migrations.
11+
*/
12+
public function up(): void
13+
{
14+
Schema::table('replies', function (Blueprint $table) {
15+
$table->unsignedBigInteger('user_id')->nullable()->change();
16+
});
17+
}
18+
19+
/**
20+
* Reverse the migrations.
21+
*/
22+
public function down(): void
23+
{
24+
Schema::table('replies', function (Blueprint $table) {
25+
$table->unsignedBigInteger('user_id')->nullable(false)->change();
26+
});
27+
}
28+
};
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
use Illuminate\Database\Migrations\Migration;
4+
use Illuminate\Database\Schema\Blueprint;
5+
use Illuminate\Support\Facades\Schema;
6+
7+
return new class extends Migration
8+
{
9+
/**
10+
* Run the migrations.
11+
*/
12+
public function up(): void
13+
{
14+
Schema::table('replies', function (Blueprint $table) {
15+
$table->boolean('pinned')->default(false)->after('note');
16+
});
17+
}
18+
19+
/**
20+
* Reverse the migrations.
21+
*/
22+
public function down(): void
23+
{
24+
Schema::table('replies', function (Blueprint $table) {
25+
$table->dropColumn('pinned');
26+
});
27+
}
28+
};

resources/views/filament/resources/support-ticket-resource/widgets/ticket-replies.blade.php

Lines changed: 49 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,33 @@
2424
}
2525
</style>
2626
<x-filament::section heading="Conversation">
27-
{{-- Reply form at top --}}
27+
{{-- Pinned Note --}}
28+
@php
29+
$pinnedNote = $record->replies()->with('user')->where('note', true)->where('pinned', true)->first();
30+
@endphp
31+
@if ($pinnedNote)
32+
<div style="border-radius: 0.5rem; border: 2px solid #f59e0b; padding: 0.75rem; background-color: #fffbeb; margin-bottom: 1rem;">
33+
<div style="display: flex; align-items: center; justify-content: space-between; gap: 0.5rem;">
34+
<div style="display: flex; align-items: center; gap: 0.5rem;">
35+
<x-filament::badge color="warning" size="sm">Pinned Note</x-filament::badge>
36+
<span style="font-size: 0.875rem; font-weight: 600; color: #111827;">
37+
{{ $pinnedNote->user?->name ?? 'System' }}
38+
</span>
39+
</div>
40+
<div style="display: flex; align-items: center; gap: 0.5rem;">
41+
<span style="font-size: 0.75rem; color: #6b7280;">
42+
{{ $pinnedNote->created_at->diffForHumans() }}
43+
</span>
44+
<x-filament::button size="xs" color="gray" wire:click="togglePin({{ $pinnedNote->id }})">
45+
Unpin
46+
</x-filament::button>
47+
</div>
48+
</div>
49+
<div class="fi-prose ticket-reply-message" style="margin-top: 0.25rem; font-size: 0.875rem; color: #374151;">{!! App\Support\CommonMark\CommonMark::convertToHtml($pinnedNote->message) !!}</div>
50+
</div>
51+
@endif
52+
53+
{{-- Reply form --}}
2854
<form wire:submit="sendReply" style="margin-bottom: 1.5rem;">
2955
<div style="display: flex; flex-direction: column; gap: 0.75rem;">
3056
<textarea
@@ -76,18 +102,32 @@
76102
<div style="border-radius: 0.5rem; border: 1px solid {{ $borderColor }}; padding: 0.75rem; background-color: {{ $bgColor }};">
77103
<div style="display: flex; align-items: center; justify-content: space-between; gap: 0.5rem;">
78104
<div style="display: flex; align-items: center; gap: 0.5rem;">
79-
<span style="font-size: 0.875rem; font-weight: 600; color: #111827;">
80-
{{ $reply->user?->name ?? 'Unknown' }}
105+
@if ($reply->user_id === null)
106+
<x-filament::badge color="gray" size="sm">System</x-filament::badge>
107+
@else
108+
<span style="font-size: 0.875rem; font-weight: 600; color: #111827;">
109+
{{ $reply->user->name }}
110+
</span>
111+
@if ($isNote)
112+
<x-filament::badge color="warning" size="sm">Note</x-filament::badge>
113+
@if ($reply->pinned)
114+
<x-filament::badge color="success" size="sm">Pinned</x-filament::badge>
115+
@endif
116+
@elseif ($isAdmin)
117+
<x-filament::badge color="primary" size="sm">Staff</x-filament::badge>
118+
@endif
119+
@endif
120+
</div>
121+
<div style="display: flex; align-items: center; gap: 0.5rem;">
122+
<span style="font-size: 0.75rem; color: #6b7280;">
123+
{{ $reply->created_at->diffForHumans() }}
81124
</span>
82125
@if ($isNote)
83-
<x-filament::badge color="warning" size="sm">Note</x-filament::badge>
84-
@elseif ($isAdmin)
85-
<x-filament::badge color="primary" size="sm">Staff</x-filament::badge>
126+
<x-filament::button size="xs" color="gray" wire:click="togglePin({{ $reply->id }})">
127+
{{ $reply->pinned ? 'Unpin' : 'Pin' }}
128+
</x-filament::button>
86129
@endif
87130
</div>
88-
<span style="font-size: 0.75rem; color: #6b7280;">
89-
{{ $reply->created_at->diffForHumans() }}
90-
</span>
91131
</div>
92132
<div class="fi-prose ticket-reply-message" style="margin-top: 0.25rem; font-size: 0.875rem; color: #374151;">{!! App\Support\CommonMark\CommonMark::convertToHtml($reply->message) !!}</div>
93133
</div>

resources/views/livewire/customer/support/index.blade.php

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919
<flux:table.column>Ticket ID</flux:table.column>
2020
<flux:table.column>Subject</flux:table.column>
2121
<flux:table.column>Status</flux:table.column>
22-
<flux:table.column></flux:table.column>
2322
</flux:table.columns>
2423

2524
<flux:table.rows>
@@ -36,10 +35,6 @@
3635
<flux:table.cell>
3736
<x-customer.status-badge :status="$ticket->status->translated()" />
3837
</flux:table.cell>
39-
40-
<flux:table.cell>
41-
<flux:button size="sm" href="{{ route('customer.support.tickets.show', $ticket) }}">View</flux:button>
42-
</flux:table.cell>
4338
</flux:table.row>
4439
@endforeach
4540
</flux:table.rows>

resources/views/livewire/customer/support/show.blade.php

Lines changed: 32 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@
1212
<flux:text>Created {{ $supportTicket->created_at->format('d M Y, H:i') }}</flux:text>
1313
</div>
1414
</div>
15-
@if($supportTicket->status !== \App\SupportTicket\Status::CLOSED)
15+
@if($supportTicket->status === \App\SupportTicket\Status::CLOSED)
16+
<flux:button wire:click="reopenTicket" wire:confirm="Are you sure you want to reopen this ticket?" icon="arrow-path" variant="ghost">
17+
Reopen Ticket
18+
</flux:button>
19+
@else
1620
<flux:button wire:click="closeTicket" wire:confirm="Are you sure you want to close this ticket?" icon="x-mark" variant="ghost">
1721
Close Ticket
1822
</flux:button>
@@ -71,7 +75,7 @@
7175
</flux:accordion>
7276

7377
{{-- Messages --}}
74-
<flux:card>
78+
<div>
7579
<flux:heading size="lg" class="mb-4">Messages</flux:heading>
7680

7781
{{-- Reply Form --}}
@@ -97,24 +101,33 @@
97101
@endif
98102

99103
@foreach($supportTicket->replies->where('note', false) as $reply)
100-
<div class="flex flex-col w-full mb-6" wire:key="reply-{{ $reply->id }}">
101-
<div class="relative w-full">
102-
<div class="{{ $reply->is_from_user ? 'bg-blue-100 dark:bg-blue-900/30 border-blue-300 dark:border-blue-700 mr-10' : 'bg-zinc-100 dark:bg-zinc-800 border-zinc-300 dark:border-zinc-600 ml-10' }} p-4 rounded-lg border">
103-
<p class="font-medium text-zinc-900 dark:text-zinc-100">
104-
{{ $reply->user->name }}
105-
@if($reply->is_from_user)
106-
<span class="text-sm text-zinc-500 dark:text-zinc-400">(You)</span>
107-
@elseif($reply->is_from_admin)
108-
<span class="text-sm text-zinc-500 dark:text-zinc-400">(Staff)</span>
109-
@endif
110-
</p>
111-
<div class="prose prose-sm mt-1 max-w-none text-zinc-800 dark:prose-invert dark:text-zinc-200">{!! App\Support\CommonMark\CommonMark::convertToHtml($reply->message) !!}</div>
112-
</div>
104+
@if($reply->user_id === null)
105+
{{-- System message --}}
106+
<div class="mb-6 flex items-center gap-3" wire:key="reply-{{ $reply->id }}">
107+
<div class="h-px grow bg-zinc-200 dark:bg-zinc-700"></div>
108+
<span class="shrink-0 text-xs text-zinc-400 dark:text-zinc-500">{{ $reply->message }} &middot; {{ $reply->created_at->format('d M Y, H:i') }}</span>
109+
<div class="h-px grow bg-zinc-200 dark:bg-zinc-700"></div>
113110
</div>
114-
<div class="mt-1 {{ $reply->is_from_user ? 'text-right mr-10' : 'text-left ml-10' }}">
115-
<span class="text-xs text-zinc-500 dark:text-zinc-400">{{ $reply->created_at->format('d M Y, H:i') }}</span>
111+
@else
112+
<div class="flex flex-col w-full mb-6" wire:key="reply-{{ $reply->id }}">
113+
<div class="relative w-full">
114+
<div class="{{ $reply->is_from_user ? 'bg-blue-100 dark:bg-blue-900/30 border-blue-300 dark:border-blue-700 mr-10' : 'bg-zinc-100 dark:bg-zinc-800 border-zinc-300 dark:border-zinc-600 ml-10' }} p-4 rounded-lg border">
115+
<p class="font-medium text-zinc-900 dark:text-zinc-100">
116+
{{ $reply->user->name }}
117+
@if($reply->is_from_user)
118+
<span class="text-sm text-zinc-500 dark:text-zinc-400">(You)</span>
119+
@elseif($reply->is_from_admin)
120+
<span class="text-sm text-zinc-500 dark:text-zinc-400">(Staff)</span>
121+
@endif
122+
</p>
123+
<div class="prose prose-sm mt-1 max-w-none text-zinc-800 dark:prose-invert dark:text-zinc-200">{!! App\Support\CommonMark\CommonMark::convertToHtml($reply->message) !!}</div>
124+
</div>
125+
</div>
126+
<div class="mt-1 {{ $reply->is_from_user ? 'text-right mr-10' : 'text-left ml-10' }}">
127+
<span class="text-xs text-zinc-500 dark:text-zinc-400">{{ $reply->created_at->format('d M Y, H:i') }}</span>
128+
</div>
116129
</div>
117-
</div>
130+
@endif
118131
@endforeach
119-
</flux:card>
132+
</div>
120133
</div>

0 commit comments

Comments
 (0)