fix(ticket-mobile): secure attachment preview and per-file upload UX

This commit is contained in:
michele 2026-03-11 23:59:15 +00:00
parent 11f078b8b2
commit 90cb8514ff
8 changed files with 340 additions and 23 deletions

View File

@ -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,
];

View File

@ -59,6 +59,12 @@ class TicketMobile extends Page
/** @var array<int, mixed> */
public array $newTicketAttachments = [];
/** @var array<int, mixed> */
public array $newTicketCameraShots = [];
/** @var array<int, string> */
public array $newTicketAttachmentDescriptions = [];
public ?string $newTicketFotoNote = null;
/** @var \Illuminate\Support\Collection<int, Ticket> */
@ -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<int, array{index:int,name:string,mime:string,is_image:bool,preview_url:?string,description:string}>
*/
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);

View File

@ -0,0 +1,51 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\TicketAttachment;
use App\Models\User;
use App\Support\StabileContext;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage;
class TicketAttachmentViewController extends Controller
{
public function show(TicketAttachment $attachment): Response
{
$user = Auth::user();
if (! $user instanceof User) {
abort(403);
}
if (! $user->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))) . '"',
]);
}
}

View File

@ -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));
}
}

View File

@ -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] |

View File

@ -199,12 +199,27 @@
<x-filament::button color="success" wire:click="caricaAllegati">Carica allegati</x-filament::button>
</div>
<div class="mt-4 space-y-2">
<div class="mt-4 grid gap-3 sm:grid-cols-2">
@forelse($ticket->attachments as $a)
<div class="rounded-md border p-2 text-xs">
<div class="font-medium">{{ $a->original_file_name }}</div>
<div class="text-gray-500">{{ $a->description }}</div>
<button type="button" wire:click="openAttachmentPreview({{ (int) $a->id }})" class="text-primary-600 hover:underline">Apri allegato</button>
<div class="rounded-xl border bg-white p-3 text-xs shadow-sm">
<div class="aspect-[4/3] rounded-lg border bg-gray-50 p-2">
@if(str_starts_with((string) $a->mime_type, 'image/'))
<img src="{{ route('admin.tickets.attachments.view', ['attachment' => (int) $a->id]) }}" alt="{{ $a->original_file_name }}" class="h-full w-full rounded object-cover" />
@else
<div class="flex h-full items-center justify-center text-center text-gray-500">
<div>
<div class="text-sm font-semibold">{{ strtoupper(pathinfo((string) $a->original_file_name, PATHINFO_EXTENSION) ?: 'FILE') }}</div>
<div class="mt-1 text-[11px]">Anteprima disponibile in modal</div>
</div>
</div>
@endif
</div>
<div class="mt-2 font-medium">{{ $a->original_file_name }}</div>
<div class="mt-1 text-gray-600">{{ $a->description ?: 'Nessuna descrizione' }}</div>
<div class="mt-2 text-[11px] text-gray-500">{{ optional($a->created_at)->format('d/m/Y H:i') }}</div>
<button type="button" wire:click="openAttachmentPreview({{ (int) $a->id }})" class="mt-2 inline-flex items-center rounded-md bg-indigo-600 px-2 py-1 text-[11px] font-medium text-white hover:bg-indigo-500">Visualizza in modal</button>
</div>
@empty
<div class="text-xs text-gray-500">Nessun allegato.</div>

View File

@ -126,22 +126,57 @@
</label>
<label class="block text-sm md:col-span-2">
<span class="mb-1 block font-medium">Nota foto/documentazione (opzionale)</span>
<span class="mb-1 block font-medium">Nota generale foto/documentazione (opzionale)</span>
<textarea rows="2" wire:model.defer="newTicketFotoNote" class="w-full rounded-lg border-gray-300" placeholder="Es. perdita visibile sotto il lavello, foto allegata" ></textarea>
</label>
<label class="block text-sm md:col-span-2">
<span class="mb-1 block font-medium">Foto o documenti</span>
<input
type="file"
wire:model="newTicketAttachments"
multiple
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.txt"
capture="environment"
class="w-full rounded-lg border-gray-300 text-sm"
/>
<div class="mt-1 text-xs text-gray-500">Puoi allegare fino a 8 file (max 10MB ciascuno). Da smartphone puoi scattare una foto direttamente.</div>
<span class="mb-2 block font-medium">Acquisizione foto e allegati</span>
<div class="flex flex-wrap gap-2">
<label class="inline-flex cursor-pointer items-center rounded-md bg-emerald-600 px-3 py-2 text-xs font-medium text-white hover:bg-emerald-500">
Scatta foto
<input type="file" wire:model="newTicketCameraShots" multiple accept="image/*" capture="environment" class="hidden" />
</label>
<label class="inline-flex cursor-pointer items-center rounded-md bg-indigo-600 px-3 py-2 text-xs font-medium text-white hover:bg-indigo-500">
Aggiungi allegati
<input type="file" wire:model="newTicketAttachments" multiple accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.txt" class="hidden" />
</label>
</div>
<div class="mt-1 text-xs text-gray-500">Dopo ogni scatto puoi aggiungere subito la descrizione. Se servono altre foto usa di nuovo "Scatta foto".</div>
</label>
@if(count($this->selectedUploads) > 0)
<div class="md:col-span-2">
<div class="mb-2 text-xs font-semibold text-gray-700">Anteprima allegati selezionati (stile catalogo)</div>
<div class="grid gap-3 sm:grid-cols-2">
@foreach($this->selectedUploads as $upload)
<div class="rounded-xl border bg-white p-3 text-xs shadow-sm">
<div class="aspect-[4/3] rounded-lg border bg-gray-50 p-2">
@if($upload['is_image'] && !empty($upload['preview_url']))
<img src="{{ $upload['preview_url'] }}" alt="{{ $upload['name'] }}" class="h-full w-full rounded object-cover" />
@else
<div class="flex h-full items-center justify-center text-center text-gray-500">
<div>
<div class="text-sm font-semibold">{{ strtoupper(pathinfo($upload['name'], PATHINFO_EXTENSION) ?: 'FILE') }}</div>
<div class="mt-1 text-[11px]">{{ $upload['name'] }}</div>
</div>
</div>
@endif
</div>
<div class="mt-2 font-medium">{{ $upload['name'] }}</div>
<label class="mt-2 block">
<span class="mb-1 block text-[11px] font-medium">Descrizione foto/allegato</span>
<input type="text" wire:model.defer="newTicketAttachmentDescriptions.{{ $upload['index'] }}" class="w-full rounded-lg border-gray-300" placeholder="Es. perdita in cucina lato sifone" />
</label>
<button type="button" wire:click="removeNewTicketFile({{ (int) $upload['index'] }})" class="mt-2 inline-flex items-center rounded-md bg-rose-600 px-2 py-1 text-[11px] font-medium text-white hover:bg-rose-500">Rimuovi</button>
</div>
@endforeach
</div>
</div>
@endif
</div>
<div class="mt-3">
<x-filament::button wire:click="creaTicketRapido">Crea ticket</x-filament::button>

View File

@ -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');