Skip to content

Commit df6e082

Browse files
simonhampclaude
andcommitted
Add file upload support to support tickets
Allow customers and admins to attach files (max 5, 10MB each) when creating tickets and replying. Files are stored on a dedicated support-tickets S3/R2 disk with signed temporary download URLs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 719b6ee commit df6e082

19 files changed

Lines changed: 629 additions & 6 deletions

File tree

.cursor/rules/laravel-boost.mdc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ The Laravel Boost guidelines are specifically curated by Laravel maintainers for
1111
## Foundational Context
1212
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
1313

14-
- php - 8.5.0
14+
- php - 8.4.19
1515
- filament/filament (FILAMENT) - v5
1616
- laravel/cashier (CASHIER) - v15
1717
- laravel/framework (LARAVEL) - v12

.github/copilot-instructions.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ The Laravel Boost guidelines are specifically curated by Laravel maintainers for
88
## Foundational Context
99
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
1010

11-
- php - 8.5.0
11+
- php - 8.4.19
1212
- filament/filament (FILAMENT) - v5
1313
- laravel/cashier (CASHIER) - v15
1414
- laravel/framework (LARAVEL) - v12

.junie/guidelines.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ The Laravel Boost guidelines are specifically curated by Laravel maintainers for
88
## Foundational Context
99
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
1010

11-
- php - 8.5.0
11+
- php - 8.4.19
1212
- filament/filament (FILAMENT) - v5
1313
- laravel/cashier (CASHIER) - v15
1414
- laravel/framework (LARAVEL) - v12

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ The Laravel Boost guidelines are specifically curated by Laravel maintainers for
88
## Foundational Context
99
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
1010

11-
- php - 8.5.0
11+
- php - 8.4.19
1212
- filament/filament (FILAMENT) - v5
1313
- laravel/cashier (CASHIER) - v15
1414
- laravel/framework (LARAVEL) - v12

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ The Laravel Boost guidelines are specifically curated by Laravel maintainers for
88
## Foundational Context
99
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
1010

11-
- php - 8.5.0
11+
- php - 8.4.19
1212
- filament/filament (FILAMENT) - v5
1313
- laravel/cashier (CASHIER) - v15
1414
- laravel/framework (LARAVEL) - v12

app/Filament/Resources/SupportTicketResource.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
use Filament\Schemas\Schema;
1313
use Filament\Tables;
1414
use Filament\Tables\Table;
15+
use Illuminate\Support\HtmlString;
1516

1617
class SupportTicketResource extends Resource
1718
{
@@ -77,6 +78,24 @@ public static function infolist(Schema $schema): Schema
7778
Infolists\Components\TextEntry::make('message')
7879
->label('Message')
7980
->markdown(),
81+
Infolists\Components\TextEntry::make('attachments')
82+
->label('Attachments')
83+
->formatStateUsing(function (SupportTicket $record): HtmlString {
84+
$attachments = $record->attachments;
85+
86+
if (empty($attachments)) {
87+
return new HtmlString('<span style="color: #9ca3af;">None</span>');
88+
}
89+
90+
$links = collect($attachments)->map(function (array $attachment, int $index) use ($record): string {
91+
$url = route('customer.support.tickets.attachment', [$record, $index]);
92+
93+
return '<a href="'.e($url).'" target="_blank" style="color: #2563eb; text-decoration: underline;">'.e($attachment['name']).'</a>';
94+
});
95+
96+
return new HtmlString($links->implode('<br>'));
97+
})
98+
->html(),
8099
])
81100
->collapsible()
82101
->persistCollapsed(),

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,12 @@
66
use App\Notifications\SupportTicketReplied;
77
use Filament\Widgets\Widget;
88
use Illuminate\Database\Eloquent\Model;
9+
use Livewire\WithFileUploads;
910

1011
class TicketRepliesWidget extends Widget
1112
{
13+
use WithFileUploads;
14+
1215
protected string $view = 'filament.resources.support-ticket-resource.widgets.ticket-replies';
1316

1417
public ?Model $record = null;
@@ -17,6 +20,8 @@ class TicketRepliesWidget extends Widget
1720

1821
public bool $isNote = false;
1922

23+
public array $replyAttachments = [];
24+
2025
protected int|string|array $columnSpan = 'full';
2126

2227
protected function getListeners(): array
@@ -28,12 +33,32 @@ public function sendReply(): void
2833
{
2934
$this->validate([
3035
'newMessage' => ['required', 'string', 'max:5000'],
36+
'replyAttachments' => ['array', 'max:5'],
37+
'replyAttachments.*' => ['file', 'max:10240'],
3138
]);
3239

40+
$attachments = null;
41+
42+
if (! empty($this->replyAttachments)) {
43+
$attachments = [];
44+
45+
foreach ($this->replyAttachments as $file) {
46+
$path = $file->store("support-tickets/{$this->record->mask}/replies", 'support-tickets');
47+
48+
$attachments[] = [
49+
'name' => $file->getClientOriginalName(),
50+
'path' => $path,
51+
'size' => $file->getSize(),
52+
'mime_type' => $file->getMimeType(),
53+
];
54+
}
55+
}
56+
3357
$reply = $this->record->replies()->create([
3458
'user_id' => auth()->id(),
3559
'message' => $this->newMessage,
3660
'note' => $this->isNote,
61+
'attachments' => $attachments,
3762
]);
3863

3964
if (! $this->isNote && $this->record->user_id !== auth()->id()) {
@@ -42,6 +67,14 @@ public function sendReply(): void
4267

4368
$this->newMessage = '';
4469
$this->isNote = false;
70+
$this->replyAttachments = [];
71+
}
72+
73+
public function removeReplyAttachment(int $index): void
74+
{
75+
$attachments = $this->replyAttachments;
76+
array_splice($attachments, $index, 1);
77+
$this->replyAttachments = $attachments;
4578
}
4679

4780
public function togglePin(int $replyId): void
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<?php
2+
3+
namespace App\Http\Controllers;
4+
5+
use App\Models\SupportTicket;
6+
use App\Models\SupportTicket\Reply;
7+
use Illuminate\Http\RedirectResponse;
8+
use Illuminate\Support\Facades\Storage;
9+
10+
class SupportTicketAttachmentController extends Controller
11+
{
12+
public function downloadTicketAttachment(SupportTicket $supportTicket, int $index): RedirectResponse
13+
{
14+
$user = auth()->user();
15+
16+
abort_unless($user->id === $supportTicket->user_id || $user->isAdmin(), 403);
17+
18+
$attachments = $supportTicket->attachments ?? [];
19+
20+
abort_unless(isset($attachments[$index]), 404);
21+
22+
$attachment = $attachments[$index];
23+
24+
$url = Storage::disk('support-tickets')->temporaryUrl($attachment['path'], now()->addMinutes(5));
25+
26+
return redirect($url);
27+
}
28+
29+
public function downloadReplyAttachment(SupportTicket $supportTicket, Reply $reply, int $index): RedirectResponse
30+
{
31+
$user = auth()->user();
32+
33+
abort_unless($user->id === $supportTicket->user_id || $user->isAdmin(), 403);
34+
abort_unless($reply->support_ticket_id === $supportTicket->id, 404);
35+
36+
$attachments = $reply->attachments ?? [];
37+
38+
abort_unless(isset($attachments[$index]), 404);
39+
40+
$attachment = $attachments[$index];
41+
42+
$url = Storage::disk('support-tickets')->temporaryUrl($attachment['path'], now()->addMinutes(5));
43+
44+
return redirect($url);
45+
}
46+
}

app/Livewire/Customer/Support/Create.php

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,14 @@
1313
use Livewire\Attributes\Locked;
1414
use Livewire\Attributes\Title;
1515
use Livewire\Component;
16+
use Livewire\WithFileUploads;
1617

1718
#[Layout('components.layouts.dashboard')]
1819
#[Title('Submit a Request')]
1920
class Create extends Component
2021
{
22+
use WithFileUploads;
23+
2124
public function boot(): void
2225
{
2326
abort_unless(auth()->user()->hasUltraAccess(), 403);
@@ -44,6 +47,9 @@ public function boot(): void
4447

4548
public string $environment = '';
4649

50+
/** File uploads */
51+
public array $uploads = [];
52+
4753
/** Step 3: Subject + Message */
4854
public string $subject = '';
4955

@@ -58,6 +64,7 @@ public function updatedSelectedProduct(): void
5864
$this->whatHappened = '';
5965
$this->reproductionSteps = '';
6066
$this->environment = '';
67+
$this->uploads = [];
6168
$this->subject = '';
6269
$this->message = '';
6370
$this->resetValidation();
@@ -140,6 +147,23 @@ public function submit(): void
140147
'metadata' => $metadata ?: null,
141148
]);
142149

150+
if (! empty($this->uploads)) {
151+
$attachments = [];
152+
153+
foreach ($this->uploads as $file) {
154+
$path = $file->store("support-tickets/{$ticket->mask}", 'support-tickets');
155+
156+
$attachments[] = [
157+
'name' => $file->getClientOriginalName(),
158+
'path' => $path,
159+
'size' => $file->getSize(),
160+
'mime_type' => $file->getMimeType(),
161+
];
162+
}
163+
164+
$ticket->update(['attachments' => $attachments]);
165+
}
166+
143167
Notification::route('mail', 'support@nativephp.com')
144168
->notify(new SupportTicketSubmitted($ticket));
145169

@@ -200,9 +224,22 @@ protected function validateStep2(): void
200224
$messages['issueType.required'] = 'Please select an issue type.';
201225
}
202226

227+
$rules['uploads'] = ['array', 'max:5'];
228+
$rules['uploads.*'] = ['file', 'max:10240'];
229+
230+
$messages['uploads.max'] = 'You may attach up to 5 files.';
231+
$messages['uploads.*.max'] = 'Each file must not exceed 10MB.';
232+
203233
$this->validate($rules, $messages);
204234
}
205235

236+
public function removeUpload(int $index): void
237+
{
238+
$uploads = $this->uploads;
239+
array_splice($uploads, $index, 1);
240+
$this->uploads = $uploads;
241+
}
242+
206243
public function render()
207244
{
208245
$officialPlugins = collect();

app/Livewire/Customer/Support/Show.php

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,20 @@
1111
use Livewire\Attributes\Layout;
1212
use Livewire\Attributes\Title;
1313
use Livewire\Component;
14+
use Livewire\WithFileUploads;
1415

1516
#[Layout('components.layouts.dashboard')]
1617
#[Title('Support Ticket')]
1718
class Show extends Component
1819
{
20+
use WithFileUploads;
21+
1922
public SupportTicket $supportTicket;
2023

2124
public string $replyMessage = '';
2225

26+
public array $replyAttachments = [];
27+
2328
public function mount(SupportTicket $supportTicket): void
2429
{
2530
abort_unless(auth()->user()->hasUltraAccess(), 403);
@@ -46,20 +51,41 @@ public function reply(): void
4651

4752
$this->validate([
4853
'replyMessage' => ['required', 'string', 'max:5000'],
54+
'replyAttachments' => ['array', 'max:5'],
55+
'replyAttachments.*' => ['file', 'max:10240'],
4956
]);
5057

5158
RateLimiter::hit($key, 60);
5259

60+
$attachments = null;
61+
62+
if (! empty($this->replyAttachments)) {
63+
$attachments = [];
64+
65+
foreach ($this->replyAttachments as $file) {
66+
$path = $file->store("support-tickets/{$this->supportTicket->mask}/replies", 'support-tickets');
67+
68+
$attachments[] = [
69+
'name' => $file->getClientOriginalName(),
70+
'path' => $path,
71+
'size' => $file->getSize(),
72+
'mime_type' => $file->getMimeType(),
73+
];
74+
}
75+
}
76+
5377
$reply = $this->supportTicket->replies()->create([
5478
'user_id' => auth()->id(),
5579
'message' => $this->replyMessage,
5680
'note' => false,
81+
'attachments' => $attachments,
5782
]);
5883

5984
Notification::route('mail', 'support@nativephp.com')
6085
->notify(new SupportTicketUserReplied($this->supportTicket, $reply));
6186

6287
$this->replyMessage = '';
88+
$this->replyAttachments = [];
6389
$this->supportTicket->load(['user', 'replies.user']);
6490
}
6591

@@ -80,6 +106,13 @@ public function closeTicket(): void
80106
$this->supportTicket->load(['user', 'replies.user']);
81107
}
82108

109+
public function removeReplyAttachment(int $index): void
110+
{
111+
$attachments = $this->replyAttachments;
112+
array_splice($attachments, $index, 1);
113+
$this->replyAttachments = $attachments;
114+
}
115+
83116
public function reopenTicket(): void
84117
{
85118
$this->authorize('reopenTicket', $this->supportTicket);

0 commit comments

Comments
 (0)