From 90cb8514ffb5a8c5438e8474cb23aa47644550de Mon Sep 17 00:00:00 2001 From: michele Date: Wed, 11 Mar 2026 23:59:15 +0000 Subject: [PATCH] fix(ticket-mobile): secure attachment preview and per-file upload UX --- .../Pages/Supporto/TicketGestione.php | 6 +- app/Filament/Pages/Supporto/TicketMobile.php | 111 +++++++++++++++++- .../Admin/TicketAttachmentViewController.php | 51 ++++++++ .../Api/PanasonicCstaController.php | 108 +++++++++++++++++ docs/NETGESCON-MODIFICHE.md | 2 + .../pages/supporto/ticket-gestione.blade.php | 25 +++- .../pages/supporto/ticket-mobile.blade.php | 57 +++++++-- routes/web.php | 3 + 8 files changed, 340 insertions(+), 23 deletions(-) create mode 100644 app/Http/Controllers/Admin/TicketAttachmentViewController.php diff --git a/app/Filament/Pages/Supporto/TicketGestione.php b/app/Filament/Pages/Supporto/TicketGestione.php index cef6b8a..7047bc4 100644 --- a/app/Filament/Pages/Supporto/TicketGestione.php +++ b/app/Filament/Pages/Supporto/TicketGestione.php @@ -151,9 +151,9 @@ public function getFornitoreAnagraficaUrl(int $fornitoreId): string return \App\Filament\Pages\Gescon\FornitoreScheda::getUrl(['record' => $fornitoreId], panel: 'admin-filament'); } - public function getAttachmentPublicUrl(string $filePath): string + public function getAttachmentPublicUrl(TicketAttachment $attachment): string { - return '/storage/' . ltrim($filePath, '/'); + return route('admin.tickets.attachments.view', ['attachment' => (int) $attachment->id]); } public function openAttachmentPreview(int $attachmentId): void @@ -179,7 +179,7 @@ public function openAttachmentPreview(int $attachmentId): void 'id' => (int) $attachment->id, 'name' => $name, 'description' => (string) ($attachment->description ?? ''), - 'url' => $this->getAttachmentPublicUrl((string) $attachment->file_path), + 'url' => $this->getAttachmentPublicUrl($attachment), 'is_image' => $isImage, 'is_pdf' => $isPdf, ]; diff --git a/app/Filament/Pages/Supporto/TicketMobile.php b/app/Filament/Pages/Supporto/TicketMobile.php index 698b211..d1955e3 100644 --- a/app/Filament/Pages/Supporto/TicketMobile.php +++ b/app/Filament/Pages/Supporto/TicketMobile.php @@ -59,6 +59,12 @@ class TicketMobile extends Page /** @var array */ public array $newTicketAttachments = []; + /** @var array */ + public array $newTicketCameraShots = []; + + /** @var array */ + public array $newTicketAttachmentDescriptions = []; + public ?string $newTicketFotoNote = null; /** @var \Illuminate\Support\Collection */ @@ -291,8 +297,12 @@ public function creaTicketRapido(): void 'newTicketCategoriaId' => ['nullable', 'integer', 'exists:categorie_ticket,id'], 'newTicketTipoIntervento' => ['nullable', 'string', 'max:80'], 'newTicketFotoNote' => ['nullable', 'string', 'max:2000'], + 'newTicketCameraShots' => ['nullable', 'array', 'max:8'], + 'newTicketCameraShots.*' => ['file', 'max:10240', 'mimes:jpg,jpeg,png,webp'], 'newTicketAttachments' => ['nullable', 'array', 'max:8'], 'newTicketAttachments.*' => ['file', 'max:10240', 'mimes:jpg,jpeg,png,webp,pdf,doc,docx,xls,xlsx,txt'], + 'newTicketAttachmentDescriptions' => ['nullable', 'array'], + 'newTicketAttachmentDescriptions.*' => ['nullable', 'string', 'max:255'], ]); $user = Auth::user(); @@ -371,11 +381,78 @@ public function creaTicketRapido(): void $this->newTicketPriorita = 'Media'; $this->newTicketCategoriaId = null; $this->newTicketTipoIntervento = 'altro'; + $this->newTicketCameraShots = []; $this->newTicketAttachments = []; + $this->newTicketAttachmentDescriptions = []; $this->newTicketFotoNote = null; $this->refreshData(); } + public function updatedNewTicketCameraShots(): void + { + $this->syncAttachmentDescriptionSlots(); + } + + public function updatedNewTicketAttachments(): void + { + $this->syncAttachmentDescriptionSlots(); + } + + public function removeNewTicketFile(int $index): void + { + $cameraCount = count($this->newTicketCameraShots); + + if ($index < $cameraCount) { + unset($this->newTicketCameraShots[$index]); + $this->newTicketCameraShots = array_values($this->newTicketCameraShots); + } else { + $docIndex = $index - $cameraCount; + unset($this->newTicketAttachments[$docIndex]); + $this->newTicketAttachments = array_values($this->newTicketAttachments); + } + + $this->syncAttachmentDescriptionSlots(); + } + + /** + * @return array + */ + public function getSelectedUploadsProperty(): array + { + $rows = []; + $files = array_merge($this->newTicketCameraShots, $this->newTicketAttachments); + + foreach ($files as $index => $file) { + if (! is_object($file)) { + continue; + } + + $name = (string) (method_exists($file, 'getClientOriginalName') ? $file->getClientOriginalName() : ('file-' . $index)); + $mime = (string) (method_exists($file, 'getClientMimeType') ? $file->getClientMimeType() : 'application/octet-stream'); + $isImage = str_starts_with($mime, 'image/'); + + $previewUrl = null; + if ($isImage && method_exists($file, 'temporaryUrl')) { + try { + $previewUrl = (string) $file->temporaryUrl(); + } catch (Throwable) { + $previewUrl = null; + } + } + + $rows[] = [ + 'index' => (int) $index, + 'name' => $name, + 'mime' => $mime, + 'is_image' => $isImage, + 'preview_url' => $previewUrl, + 'description' => (string) ($this->newTicketAttachmentDescriptions[$index] ?? ''), + ]; + } + + return $rows; + } + private function loadCategorieTicketOptions(): void { $this->categorieTicketOptions = CategoriaTicket::query() @@ -397,7 +474,9 @@ private function salvaAllegatiTicket(Ticket $ticket, int $userId): int $saved = 0; - foreach ($this->newTicketAttachments as $file) { + $files = array_merge($this->newTicketCameraShots, $this->newTicketAttachments); + + foreach ($files as $index => $file) { if (! is_object($file) || ! method_exists($file, 'store')) { continue; } @@ -413,9 +492,7 @@ private function salvaAllegatiTicket(Ticket $ticket, int $userId): int 'original_file_name' => (string) (method_exists($file, 'getClientOriginalName') ? $file->getClientOriginalName() : basename($path)), 'mime_type' => (string) (method_exists($file, 'getClientMimeType') ? $file->getClientMimeType() : 'application/octet-stream'), 'size' => (int) (method_exists($file, 'getSize') ? $file->getSize() : 0), - 'description' => filled($this->newTicketFotoNote) - ? ('Nota foto: ' . trim((string) $this->newTicketFotoNote)) - : 'Allegato da Ticket Mobile', + 'description' => $this->resolveAttachmentDescription((int) $index), ]); $saved++; @@ -430,6 +507,32 @@ private function salvaAllegatiTicket(Ticket $ticket, int $userId): int return $saved; } + private function syncAttachmentDescriptionSlots(): void + { + $files = array_merge($this->newTicketCameraShots, $this->newTicketAttachments); + $next = []; + + foreach ($files as $index => $_file) { + $next[$index] = (string) ($this->newTicketAttachmentDescriptions[$index] ?? ''); + } + + $this->newTicketAttachmentDescriptions = $next; + } + + private function resolveAttachmentDescription(int $index): string + { + $specific = trim((string) ($this->newTicketAttachmentDescriptions[$index] ?? '')); + if ($specific !== '') { + return $specific; + } + + if (filled($this->newTicketFotoNote)) { + return 'Nota foto: ' . trim((string) $this->newTicketFotoNote); + } + + return 'Allegato da Ticket Mobile'; + } + public function excerpt(?string $text, int $limit = 160): string { return Str::limit((string) $text, $limit); diff --git a/app/Http/Controllers/Admin/TicketAttachmentViewController.php b/app/Http/Controllers/Admin/TicketAttachmentViewController.php new file mode 100644 index 0000000..6506e62 --- /dev/null +++ b/app/Http/Controllers/Admin/TicketAttachmentViewController.php @@ -0,0 +1,51 @@ +hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore'])) { + abort(403); + } + + $ticket = $attachment->ticket; + if (! $ticket) { + abort(404); + } + + $stabileId = StabileContext::resolveActiveStabileId($user); + if (! $stabileId || (int) $ticket->stabile_id !== (int) $stabileId) { + abort(403); + } + + $disk = Storage::disk('public'); + $path = (string) $attachment->file_path; + if (! $disk->exists($path)) { + abort(404); + } + + $absolutePath = $disk->path($path); + $mime = (string) ($attachment->mime_type ?: $disk->mimeType($path) ?: 'application/octet-stream'); + + return response()->file($absolutePath, [ + 'Content-Type' => $mime, + 'Cache-Control' => 'private, max-age=300', + 'Content-Disposition' => 'inline; filename="' . addslashes((string) ($attachment->original_file_name ?: basename($path))) . '"', + ]); + } +} diff --git a/app/Http/Controllers/Api/PanasonicCstaController.php b/app/Http/Controllers/Api/PanasonicCstaController.php index a3535b8..65e3b72 100644 --- a/app/Http/Controllers/Api/PanasonicCstaController.php +++ b/app/Http/Controllers/Api/PanasonicCstaController.php @@ -11,15 +11,19 @@ use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Schema; +use Illuminate\Support\Str; class PanasonicCstaController extends Controller { public function incoming(Request $request): JsonResponse { if (! $this->isAuthorized($request)) { + $this->logUnauthorizedAttempt($request, 'incoming'); return response()->json(['ok' => false, 'message' => 'Unauthorized'], 401); } + $traceId = (string) Str::uuid(); + $payload = $request->validate([ 'phone' => ['required', 'string', 'max:64'], 'name' => ['nullable', 'string', 'max:255'], @@ -29,6 +33,14 @@ public function incoming(Request $request): JsonResponse 'called_extension' => ['nullable', 'string', 'max:30'], 'note' => ['nullable', 'string'], 'called_at' => ['nullable', 'date'], + 'is_test' => ['nullable', 'boolean'], + ]); + + [$isTest, $classification] = $this->classifyCall($payload); + $this->logTrace('incoming.received', $request, $payload, [ + 'trace_id' => $traceId, + 'classification' => $classification, + 'is_test' => $isTest, ]); $normalized = PhoneNumber::normalizeForMatch((string) $payload['phone']); @@ -57,9 +69,18 @@ public function incoming(Request $request): JsonResponse $this->mirrorToPeerIfEnabled($request, 'incoming', $payload); + $this->logTrace('incoming.stored', $request, $payload, [ + 'trace_id' => $traceId, + 'post_it_id' => $postIt?->id, + 'rubrica_id' => $rubrica?->id, + 'classification' => $classification, + 'is_test' => $isTest, + ]); + return response()->json([ 'ok' => true, 'source' => 'panasonic_ns1000', + 'trace_id' => $traceId, 'phone_normalized' => $normalized, 'matched' => (bool) $rubrica, 'rubrica' => $rubrica ? [ @@ -75,6 +96,7 @@ public function incoming(Request $request): JsonResponse public function lookup(Request $request): JsonResponse { if (! $this->isAuthorized($request)) { + $this->logUnauthorizedAttempt($request, 'lookup'); return response()->json(['ok' => false, 'message' => 'Unauthorized'], 401); } @@ -103,9 +125,12 @@ public function lookup(Request $request): JsonResponse public function callEnded(Request $request): JsonResponse { if (! $this->isAuthorized($request)) { + $this->logUnauthorizedAttempt($request, 'call-ended'); return response()->json(['ok' => false, 'message' => 'Unauthorized'], 401); } + $traceId = (string) Str::uuid(); + $payload = $request->validate([ 'phone' => ['nullable', 'string', 'max:64', 'required_without:event_id'], 'event_id' => ['nullable', 'string', 'max:100', 'required_without:phone'], @@ -115,6 +140,14 @@ public function callEnded(Request $request): JsonResponse 'direction' => ['nullable', 'in:in_arrivo,in_uscita,persa'], 'called_extension' => ['nullable', 'string', 'max:30'], 'note' => ['nullable', 'string'], + 'is_test' => ['nullable', 'boolean'], + ]); + + [$isTest, $classification] = $this->classifyCall($payload); + $this->logTrace('call-ended.received', $request, $payload, [ + 'trace_id' => $traceId, + 'classification' => $classification, + 'is_test' => $isTest, ]); $normalized = PhoneNumber::normalizeForMatch((string) ($payload['phone'] ?? '')); @@ -148,9 +181,16 @@ public function callEnded(Request $request): JsonResponse } if (! $postIt) { + $this->logTrace('call-ended.not-found', $request, $payload, [ + 'trace_id' => $traceId, + 'classification' => $classification, + 'is_test' => $isTest, + ]); + return response()->json([ 'ok' => false, 'message' => 'No matching Post-it and table unavailable', + 'trace_id' => $traceId, ], 404); } @@ -175,9 +215,18 @@ public function callEnded(Request $request): JsonResponse $this->mirrorToPeerIfEnabled($request, 'call-ended', $payload); + $this->logTrace('call-ended.closed', $request, $payload, [ + 'trace_id' => $traceId, + 'post_it_id' => $postIt->id, + 'created_new' => $created, + 'classification' => $classification, + 'is_test' => $isTest, + ]); + return response()->json([ 'ok' => true, 'source' => 'panasonic_ns1000', + 'trace_id' => $traceId, 'closed' => true, 'created_new' => $created, 'post_it_id' => $postIt->id, @@ -324,10 +373,17 @@ private function findOpenPostIt(string $eventId, string $phone): ?ChiamataPostIt private function buildNote(array $payload): string { + [$isTest, $classification] = $this->classifyCall($payload); + $lines = [ 'Origine: Panasonic NS1000 (CSTA)', + 'Classificazione: ' . $classification, ]; + if ($isTest) { + $lines[] = 'Flag test: SI'; + } + if (! empty($payload['called_extension'])) { $lines[] = 'Interno chiamato: ' . $payload['called_extension']; } @@ -342,4 +398,56 @@ private function buildNote(array $payload): string return implode("\n", $lines); } + + /** + * @return array{0:bool,1:string} + */ + private function classifyCall(array $payload): array + { + if (array_key_exists('is_test', $payload)) { + $isTest = (bool) $payload['is_test']; + return [$isTest, $isTest ? 'test (flag esplicito)' : 'reale presunta (flag esplicito)']; + } + + $haystack = mb_strtolower(trim(implode(' ', [ + (string) ($payload['name'] ?? ''), + (string) ($payload['note'] ?? ''), + (string) ($payload['event_id'] ?? ''), + ]))); + + foreach (['test', 'prova', 'demo', 'simulazione'] as $marker) { + if ($haystack !== '' && str_contains($haystack, $marker)) { + return [true, 'test probabile (marker: ' . $marker . ')']; + } + } + + return [false, 'reale presunta']; + } + + private function logUnauthorizedAttempt(Request $request, string $endpoint): void + { + Log::warning('CTI Panasonic unauthorized', [ + 'endpoint' => $endpoint, + 'ip' => $request->ip(), + 'user_agent' => (string) $request->userAgent(), + 'has_token_header' => $request->headers->has('X-CTI-Token'), + ]); + } + + private function logTrace(string $event, Request $request, array $payload, array $extra = []): void + { + $phoneRaw = (string) ($payload['phone'] ?? ''); + $phoneDigits = PhoneNumber::normalizeDigits($phoneRaw); + + Log::info('CTI Panasonic trace', array_merge([ + 'event' => $event, + 'ip' => $request->ip(), + 'user_agent' => (string) $request->userAgent(), + 'mirrored' => (string) $request->header('X-CTI-Mirrored', '0') === '1', + 'event_id' => (string) ($payload['event_id'] ?? ''), + 'called_extension' => (string) ($payload['called_extension'] ?? ''), + 'direction' => (string) ($payload['direction'] ?? ''), + 'phone_tail' => $phoneDigits !== '' ? substr($phoneDigits, -4) : '', + ], $extra)); + } } diff --git a/docs/NETGESCON-MODIFICHE.md b/docs/NETGESCON-MODIFICHE.md index 9308ccd..a7ef240 100644 --- a/docs/NETGESCON-MODIFICHE.md +++ b/docs/NETGESCON-MODIFICHE.md @@ -21,3 +21,5 @@ # Modifiche NetGescon | 11/03/2026 | 0.8.x-dev | Day-0 / Staging / Operativita | Handoff operativo completo, comandi diagnostica payload CSTA/HTTP e allineamento staging nginx su /var/www/netgescon:80 | [P] | | 11/03/2026 | 0.8.x-dev | CTI Panasonic / DB condiviso | Verificato allineamento DB dev/staging su base unica, applicata migrazione campi `durata_secondi`/`chiusa_il`, fallback token CTI multi-chiave e test end-to-end incoming/call-ended/lookup con chiusura Post-it validata | [U] | | 11/03/2026 | 0.8.x-dev | CTI Panasonic / Sync staging->sviluppo | Introdotti mirror payload CTI opzionale (solo staging) e comando `cti:sync-postit-from-staging` per riallineamento unidirezionale record `chiamate_post_it` Panasonic verso sviluppo | [U] | +| 11/03/2026 | 0.8.x-dev | Ticket Mobile / Allegati | Fix accesso allegati con route protetta `admin.tickets.attachments.view` (stop 403 da URL `/storage`), anteprima in modal e lista allegati stile catalogo con descrizione per file in inserimento mobile (foto + documenti) | [U] | +| 11/03/2026 | 0.8.x-dev | CTI Panasonic / Tracciamento | Aggiunto `trace_id` in risposta incoming/call-ended e log strutturato (`CTI Panasonic trace`) per audit chiamate reali/test, ricerca eventi mancanti e diagnosi `event_id`/telefono/canale mirror | [U] | diff --git a/resources/views/filament/pages/supporto/ticket-gestione.blade.php b/resources/views/filament/pages/supporto/ticket-gestione.blade.php index 5d93051..975ff13 100644 --- a/resources/views/filament/pages/supporto/ticket-gestione.blade.php +++ b/resources/views/filament/pages/supporto/ticket-gestione.blade.php @@ -199,12 +199,27 @@ Carica allegati -
+
@forelse($ticket->attachments as $a) -
-
{{ $a->original_file_name }}
-
{{ $a->description }}
- +
+
+ @if(str_starts_with((string) $a->mime_type, 'image/')) + {{ $a->original_file_name }} + @else +
+
+
{{ strtoupper(pathinfo((string) $a->original_file_name, PATHINFO_EXTENSION) ?: 'FILE') }}
+
Anteprima disponibile in modal
+
+
+ @endif +
+ +
{{ $a->original_file_name }}
+
{{ $a->description ?: 'Nessuna descrizione' }}
+
{{ optional($a->created_at)->format('d/m/Y H:i') }}
+ +
@empty
Nessun allegato.
diff --git a/resources/views/filament/pages/supporto/ticket-mobile.blade.php b/resources/views/filament/pages/supporto/ticket-mobile.blade.php index c008cde..8eb3570 100644 --- a/resources/views/filament/pages/supporto/ticket-mobile.blade.php +++ b/resources/views/filament/pages/supporto/ticket-mobile.blade.php @@ -126,22 +126,57 @@ + + @if(count($this->selectedUploads) > 0) +
+
Anteprima allegati selezionati (stile catalogo)
+
+ @foreach($this->selectedUploads as $upload) +
+
+ @if($upload['is_image'] && !empty($upload['preview_url'])) + {{ $upload['name'] }} + @else +
+
+
{{ strtoupper(pathinfo($upload['name'], PATHINFO_EXTENSION) ?: 'FILE') }}
+
{{ $upload['name'] }}
+
+
+ @endif +
+ +
{{ $upload['name'] }}
+ + + +
+ @endforeach +
+
+ @endif
Crea ticket diff --git a/routes/web.php b/routes/web.php index c50edb1..16319c0 100755 --- a/routes/web.php +++ b/routes/web.php @@ -19,6 +19,7 @@ use App\Http\Controllers\Admin\StabileAttivoController; use App\Http\Controllers\Admin\StabileController; use App\Http\Controllers\Admin\TicketController; +use App\Http\Controllers\Admin\TicketAttachmentViewController; use App\Http\Controllers\Admin\TicketInterventoController; use App\Http\Controllers\Admin\UnitaImmobiliareController; use App\Http\Controllers\Admin\VoceSpesaController; @@ -131,6 +132,7 @@ Route::resource('tickets', TicketController::class); Route::post('tickets/{ticket}/close', [TicketController::class, 'close'])->name('tickets.close'); Route::post('tickets/{ticket}/email-message', [TicketController::class, 'storeEmailMessage'])->name('tickets.email-message.store'); + Route::get('tickets/attachments/{attachment}/view', [TicketAttachmentViewController::class, 'show'])->name('tickets.attachments.view'); Route::post('tickets/{ticket}/interventi', [TicketInterventoController::class, 'store'])->name('tickets.interventi.store'); Route::post('tickets/{ticket}/interventi/{intervento}/verify', [TicketInterventoController::class, 'verify'])->name('tickets.interventi.verify'); Route::post('tickets/{ticket}/interventi/{intervento}/invoice', [TicketInterventoController::class, 'invoice'])->name('tickets.interventi.invoice'); @@ -300,6 +302,7 @@ Route::resource('tickets', TicketController::class); Route::post('tickets/{ticket}/close', [TicketController::class, 'close'])->name('tickets.close'); Route::post('tickets/{ticket}/email-message', [TicketController::class, 'storeEmailMessage'])->name('tickets.email-message.store'); + Route::get('tickets/attachments/{attachment}/view', [TicketAttachmentViewController::class, 'show'])->name('tickets.attachments.view'); Route::post('tickets/{ticket}/interventi', [TicketInterventoController::class, 'store'])->name('tickets.interventi.store'); Route::post('tickets/{ticket}/interventi/{intervento}/verify', [TicketInterventoController::class, 'verify'])->name('tickets.interventi.verify'); Route::post('tickets/{ticket}/interventi/{intervento}/invoice', [TicketInterventoController::class, 'invoice'])->name('tickets.interventi.invoice');