From 972d2c7e83fd2a9bf792225b771bd289e9ce2b65 Mon Sep 17 00:00:00 2001 From: michele Date: Thu, 9 Apr 2026 22:37:44 +0000 Subject: [PATCH] Add multi-account Gmail ticket import and restore admin UI styling --- .../GmailImportStabileTicketsCommand.php | 83 ++++ app/Filament/Pages/Condomini/StabilePosta.php | 49 ++ .../Integrations/GoogleOAuthController.php | 92 ++-- .../Support/GmailTicketImportService.php | 417 ++++++++++++++++++ .../Support/TicketAttachmentUploadService.php | 40 +- app/Support/GoogleAccountStore.php | 336 ++++++++++++++ resources/css/filament/admin/theme.css | 19 +- .../components/legacy-admin-assets.blade.php | 28 ++ .../pages/condomini/stabile-posta.blade.php | 35 ++ .../pages/supporto/ticket-mobile.blade.php | 6 +- 10 files changed, 1061 insertions(+), 44 deletions(-) create mode 100644 app/Console/Commands/GmailImportStabileTicketsCommand.php create mode 100644 app/Services/Support/GmailTicketImportService.php create mode 100644 app/Support/GoogleAccountStore.php diff --git a/app/Console/Commands/GmailImportStabileTicketsCommand.php b/app/Console/Commands/GmailImportStabileTicketsCommand.php new file mode 100644 index 0000000..fe4b6b6 --- /dev/null +++ b/app/Console/Commands/GmailImportStabileTicketsCommand.php @@ -0,0 +1,83 @@ +resolveStabile(); + if (! $stabile instanceof Stabile) { + $this->error('Stabile non trovato. Usa --stabile-id oppure --stabile.'); + + return self::FAILURE; + } + + $posta = (array) Arr::get($stabile->configurazione_avanzata ?? [], 'posta', []); + $caselle = array_values(array_filter((array) Arr::get($posta, 'caselle', []), function ($mailbox): bool { + return is_array($mailbox) + && (bool) ($mailbox['enabled'] ?? false) + && strtolower(trim((string) ($mailbox['tipo'] ?? ''))) === 'gmail'; + })); + + $filter = trim((string) $this->option('casella')); + if ($filter !== '') { + $caselle = array_values(array_filter($caselle, static function (array $mailbox) use ($filter): bool { + return strcasecmp((string) ($mailbox['label'] ?? ''), $filter) === 0 + || strcasecmp((string) ($mailbox['email'] ?? ''), $filter) === 0; + })); + } + + if ($caselle === []) { + $this->warn('Nessuna casella Gmail attiva trovata per questo stabile.'); + + return self::SUCCESS; + } + + $max = max(1, min((int) $this->option('max'), 50)); + foreach ($caselle as $mailbox) { + $this->line('Casella: ' . (string) ($mailbox['label'] ?? $mailbox['email'] ?? 'gmail')); + + try { + $result = $service->importMailbox($stabile, $mailbox, $max); + + $this->info('Importati ' . $result['imported'] . ' messaggi, saltati ' . $result['skipped'] . ', allegati salvati ' . $result['attachments'] . '.'); + } catch (\Throwable $e) { + $this->error($e->getMessage()); + } + } + + return self::SUCCESS; + } + + private function resolveStabile(): ?Stabile + { + $stabileId = (int) $this->option('stabile-id'); + if ($stabileId > 0) { + return Stabile::query()->find($stabileId); + } + + $codice = trim((string) $this->option('stabile')); + if ($codice !== '') { + return Stabile::query() + ->where('codice_stabile', $codice) + ->orWhere('codice_interno', $codice) + ->first(); + } + + return null; + } +} \ No newline at end of file diff --git a/app/Filament/Pages/Condomini/StabilePosta.php b/app/Filament/Pages/Condomini/StabilePosta.php index a9d5f09..85d8470 100644 --- a/app/Filament/Pages/Condomini/StabilePosta.php +++ b/app/Filament/Pages/Condomini/StabilePosta.php @@ -1,7 +1,9 @@ */ + public array $googleAccountOptions = []; + public static function canAccess(): bool { $user = Auth::user(); @@ -61,6 +66,9 @@ public function mount(): void abort_unless($stabile instanceof Stabile, 404); $this->stabile = $stabile; + $this->googleAccountOptions = $stabile->amministratore instanceof Amministratore + ? app(GoogleAccountStore::class)->options($stabile->amministratore) + : []; $posta = (array) Arr::get($stabile->configurazione_avanzata ?? [], 'posta', []); @@ -120,6 +128,11 @@ public function form(Schema $schema): Schema ->label('Etichetta') ->required() ->maxLength(120), + Select::make('google_account_key') + ->label('Account Google collegato') + ->options(fn (): array => $this->googleAccountOptions) + ->visible(fn (callable $get): bool => strtolower((string) $get('tipo')) === 'gmail') + ->helperText('Per Gmail seleziona l’account Google autorizzato che leggera questa casella.'), TextInput::make('email') ->label('Email casella') ->email() @@ -153,6 +166,10 @@ public function form(Schema $schema): Schema TextInput::make('gmail_query') ->label('Filtro Gmail / query') ->maxLength(255), + Toggle::make('crea_ticket_automatico') + ->label('Crea ticket automatici dai nuovi messaggi') + ->default(true) + ->visible(fn (callable $get): bool => strtolower((string) $get('tipo')) === 'gmail'), TextInput::make('mittenti_autorizzati') ->label('Mittenti ammessi') ->maxLength(500) @@ -200,4 +217,36 @@ public function save(): void ->success() ->send(); } + + /** + * @return array> + */ + public function getGoogleAccountsSummary(): array + { + if (! $this->stabile?->amministratore instanceof Amministratore) { + return []; + } + + return array_values(app(GoogleAccountStore::class)->all($this->stabile->amministratore)); + } + + public function getStableGoogleConnectUrl(): string + { + $label = 'PEC ' . ($this->stabile?->denominazione ?: ('stabile-' . ($this->stabile?->id ?? 0))); + + return route('oauth.google.redirect', [ + 'account_key' => 'stabile-' . ($this->stabile?->id ?? 0) . '-pec', + 'label' => $label, + 'return_to' => request()->getRequestUri(), + ]); + } + + public function getTestGoogleConnectUrl(): string + { + return route('oauth.google.redirect', [ + 'account_key' => 'gmail-test', + 'label' => 'Account Google test', + 'return_to' => request()->getRequestUri(), + ]); + } } diff --git a/app/Http/Controllers/Integrations/GoogleOAuthController.php b/app/Http/Controllers/Integrations/GoogleOAuthController.php index 082811c..534e622 100644 --- a/app/Http/Controllers/Integrations/GoogleOAuthController.php +++ b/app/Http/Controllers/Integrations/GoogleOAuthController.php @@ -6,6 +6,7 @@ use App\Models\Fornitore; use App\Models\FornitoreDipendente; use App\Models\Stabile; +use App\Support\GoogleAccountStore; use App\Models\User; use App\Support\StabileContext; use Illuminate\Http\RedirectResponse; @@ -18,12 +19,7 @@ class GoogleOAuthController extends Controller { private const GOOGLE_SCOPES = [ - 'openid', - 'email', - 'profile', - 'https://www.googleapis.com/auth/calendar.events', - 'https://www.googleapis.com/auth/contacts', - 'https://www.googleapis.com/auth/drive.file', + ...GoogleAccountStore::SCOPES, ]; public function redirect(): RedirectResponse @@ -35,7 +31,11 @@ public function redirect(): RedirectResponse } $google = Arr::get($amministratore->impostazioni ?? [], 'google', []); - $oauth = Arr::get($google, 'oauth', []); + $store = app(GoogleAccountStore::class); + $accountKey = $store->normalizeAccountKey((string) request()->query('account_key', '')); + $accountLabel = trim((string) request()->query('label', '')); + $account = $store->get($amministratore, $accountKey !== '' ? $accountKey : null); + $returnTo = trim((string) request()->query('return_to', '')); $clientId = (string) ($google['client_id'] ?? config('services.google.client_id')); $clientSecret = (string) ($google['client_secret'] ?? config('services.google.client_secret')); @@ -47,7 +47,12 @@ public function redirect(): RedirectResponse } $state = bin2hex(random_bytes(20)); - session()->put('google_oauth_state', $state); + session()->put('google_oauth_context', [ + 'state' => $state, + 'account_key' => $accountKey, + 'label' => $accountLabel, + 'return_to' => str_starts_with($returnTo, '/') ? $returnTo : null, + ]); $queryParams = [ 'client_id' => $clientId, @@ -59,10 +64,15 @@ public function redirect(): RedirectResponse 'state' => $state, ]; - if (trim((string) Arr::get($oauth, 'refresh_token', '')) === '') { + $refreshToken = trim((string) ($account['refresh_token'] ?? '')); + if ($refreshToken === '') { $queryParams['prompt'] = 'consent'; } + if (! empty($account['email'])) { + $queryParams['login_hint'] = (string) $account['email']; + } + $query = http_build_query($queryParams); return redirect()->away('https://accounts.google.com/o/oauth2/v2/auth?' . $query); @@ -76,15 +86,16 @@ public function callback(): RedirectResponse abort(403, 'Amministratore non disponibile per questa sessione.'); } - $google = Arr::get($amministratore->impostazioni ?? [], 'google', []); - $existingOauth = Arr::get($google, 'oauth', []); - $clientId = (string) ($google['client_id'] ?? config('services.google.client_id')); - $clientSecret = (string) ($google['client_secret'] ?? config('services.google.client_secret')); - $redirectUri = (string) ($google['redirect_uri'] ?? config('services.google.redirect')); + $google = Arr::get($amministratore->impostazioni ?? [], 'google', []); + $store = app(GoogleAccountStore::class); + $clientId = (string) ($google['client_id'] ?? config('services.google.client_id')); + $clientSecret = (string) ($google['client_secret'] ?? config('services.google.client_secret')); + $redirectUri = (string) ($google['redirect_uri'] ?? config('services.google.redirect')); try { + $oauthContext = (array) session()->pull('google_oauth_context', []); $incomingState = (string) request()->query('state', ''); - $sessionState = (string) session()->pull('google_oauth_state', ''); + $sessionState = (string) ($oauthContext['state'] ?? ''); if ($incomingState === '' || $sessionState === '' || ! hash_equals($sessionState, $incomingState)) { return redirect('/admin-filament/impostazioni/scheda-amministratore') ->with('error', 'Stato OAuth non valido. Riprova il collegamento Google.'); @@ -113,10 +124,7 @@ public function callback(): RedirectResponse $payload = $tokenResponse->json(); $accessToken = (string) Arr::get($payload, 'access_token', ''); $refreshToken = (string) Arr::get($payload, 'refresh_token', ''); - if ($refreshToken === '') { - $refreshToken = (string) Arr::get($existingOauth, 'refresh_token', ''); - } - $expiresIn = (int) Arr::get($payload, 'expires_in', 3600); + $expiresIn = (int) Arr::get($payload, 'expires_in', 3600); $profileResponse = Http::withToken($accessToken) ->acceptJson() @@ -127,25 +135,39 @@ public function callback(): RedirectResponse $email = (string) Arr::get($profile, 'email', ''); $name = (string) Arr::get($profile, 'name', ''); - $impostazioni = $amministratore->impostazioni ?? []; - $impostazioni['google'] = array_merge($google, [ - 'oauth' => [ + $requestedKey = $store->normalizeAccountKey((string) ($oauthContext['account_key'] ?? '')); + $accountKey = $requestedKey !== '' + ? $requestedKey + : $store->normalizeAccountKey($googleUserId !== '' ? $googleUserId : ($email !== '' ? $email : 'primary')); + $accountLabel = trim((string) ($oauthContext['label'] ?? '')) ?: ($name !== '' ? $name : $email); + $existing = $store->get($amministratore, $accountKey); + + if ($refreshToken === '') { + $refreshToken = trim((string) ($existing['refresh_token'] ?? '')); + } + + $store->upsertAccount( + $amministratore, + $accountKey, + [ 'connected' => true, + 'label' => $accountLabel, 'email' => $email, 'name' => $name, 'google_user_id' => $googleUserId, 'access_token' => $accessToken, 'refresh_token' => $refreshToken, 'expires_in' => $expiresIn, - 'connected_at' => now()->toDateTimeString(), + 'connected_at' => (string) ($existing['connected_at'] ?? now()->toDateTimeString()), 'refreshed_at' => now()->toDateTimeString(), + 'scopes' => self::GOOGLE_SCOPES, ], - ]); + $existing === null + ); - $amministratore->impostazioni = $impostazioni; - $amministratore->save(); + $redirectTo = (string) ($oauthContext['return_to'] ?? ''); - return redirect('/admin-filament/impostazioni/scheda-amministratore') + return redirect($redirectTo !== '' ? $redirectTo : '/admin-filament/impostazioni/scheda-amministratore') ->with('status', 'Account Google collegato con successo.'); } catch (Throwable $e) { Log::error('Google OAuth callback failed', [ @@ -166,18 +188,14 @@ public function disconnect(): RedirectResponse abort(403, 'Amministratore non disponibile per questa sessione.'); } - $impostazioni = $amministratore->impostazioni ?? []; - $google = Arr::get($impostazioni, 'google', []); - $google['oauth'] = [ - 'connected' => false, - 'disconnected_at' => now()->toDateTimeString(), - ]; + app(GoogleAccountStore::class)->disconnectAccount( + $amministratore, + (string) request()->input('account_key', request()->query('account_key', '')) + ); - $impostazioni['google'] = $google; - $amministratore->impostazioni = $impostazioni; - $amministratore->save(); + $returnTo = trim((string) request()->input('return_to', request()->query('return_to', ''))); - return redirect('/admin-filament/impostazioni/scheda-amministratore') + return redirect($returnTo !== '' && str_starts_with($returnTo, '/') ? $returnTo : '/admin-filament/impostazioni/scheda-amministratore') ->with('status', 'Collegamento Google rimosso.'); } diff --git a/app/Services/Support/GmailTicketImportService.php b/app/Services/Support/GmailTicketImportService.php new file mode 100644 index 0000000..9fe8d41 --- /dev/null +++ b/app/Services/Support/GmailTicketImportService.php @@ -0,0 +1,417 @@ + $mailbox + * @return array{mailbox:string,imported:int,skipped:int,attachments:int} + */ + public function importMailbox(Stabile $stabile, array $mailbox, int $maxMessages = 10): array + { + $amministratore = $stabile->amministratore; + if (! $amministratore) { + throw new RuntimeException('Amministratore non disponibile per lo stabile #' . $stabile->id . '.'); + } + + $openingUserId = (int) ($amministratore->user_id ?? 0); + if ($openingUserId <= 0) { + throw new RuntimeException('Utente amministratore non disponibile per aprire i ticket dello stabile #' . $stabile->id . '.'); + } + + $accountKey = trim((string) ($mailbox['google_account_key'] ?? '')); + $token = $this->googleAccountStore->resolveAccessToken($amministratore, $accountKey, false); + if ($token === null || $token === '') { + throw new RuntimeException('Token Google non disponibile per la casella Gmail collegata allo stabile #' . $stabile->id . '.'); + } + + $query = trim((string) ($mailbox['gmail_query'] ?? '')); + $response = Http::withToken($token) + ->acceptJson() + ->get('https://gmail.googleapis.com/gmail/v1/users/me/messages', array_filter([ + 'maxResults' => max(1, min($maxMessages, 50)), + 'q' => $query !== '' ? $query : null, + ], static fn ($value) => $value !== null)); + + if (! $response->successful()) { + throw new RuntimeException('Gmail API non disponibile: ' . $response->body()); + } + + $imported = 0; + $skipped = 0; + $attachments = 0; + $messages = (array) Arr::get($response->json(), 'messages', []); + + foreach ($messages as $row) { + $gmailId = trim((string) ($row['id'] ?? '')); + if ($gmailId === '') { + continue; + } + + $message = $this->fetchMessage($token, $gmailId, 'full'); + $headers = $this->extractHeaders((array) Arr::get($message, 'payload.headers', [])); + + $headerMessageId = trim((string) ($headers['message-id'] ?? '')); + $externalId = $headerMessageId !== '' ? $headerMessageId : $gmailId; + + if ($this->alreadyImported($externalId, $gmailId)) { + $skipped++; + continue; + } + + $bodyText = $this->extractBodyText((array) Arr::get($message, 'payload', [])); + $subject = trim((string) ($headers['subject'] ?? '')); + $from = trim((string) ($headers['from'] ?? '')); + $to = trim((string) ($headers['to'] ?? '')); + $date = $headers['date'] ?? null; + + $ticket = Ticket::query()->create([ + 'stabile_id' => (int) $stabile->id, + 'aperto_da_user_id' => $openingUserId, + 'titolo' => Str::limit($subject !== '' ? $subject : ('Email importata da ' . ($from !== '' ? $from : 'Gmail')), 255), + 'descrizione' => $this->buildTicketDescription($stabile, $mailbox, $subject, $from, $to, $date, $bodyText), + 'categoria_ticket_id' => null, + 'luogo_intervento' => 'Casella email stabile', + 'data_apertura' => $this->normalizeDate($date) ?? now(), + 'stato' => 'Aperto', + 'priorita' => 'Media', + ]); + + $rawMessage = $this->fetchMessage($token, $gmailId, 'raw'); + $rawEml = $this->decodeBase64Url((string) Arr::get($rawMessage, 'raw', '')); + $documento = $this->storeRawEmailDocumento($ticket, $mailbox, $subject, $from, $to, $date, $rawEml, $gmailId, $openingUserId); + + $ticket->messages()->create([ + 'user_id' => $openingUserId, + 'messaggio' => Str::limit($bodyText !== '' ? $bodyText : ($subject !== '' ? $subject : 'Email importata da Gmail'), 4000), + 'canale' => 'email', + 'direzione' => 'inbound', + 'oggetto' => $subject !== '' ? $subject : null, + 'email_mittente' => $from !== '' ? $from : null, + 'email_destinatario' => $to !== '' ? $to : null, + 'external_message_id' => $externalId, + 'inviato_il' => $this->normalizeDate($date), + 'eml_documento_id' => $documento->id, + 'metadata' => [ + 'gmail_message_id' => $gmailId, + 'gmail_thread_id' => Arr::get($message, 'threadId'), + 'gmail_mailbox_email' => $mailbox['email'] ?? null, + 'google_account_key' => $mailbox['google_account_key'] ?? null, + ], + ]); + + CommunicationMessage::query()->create([ + 'channel' => 'email', + 'direction' => 'inbound', + 'external_message_id' => $externalId, + 'stabile_id' => (int) $stabile->id, + 'sender_name' => $from !== '' ? $from : null, + 'message_text' => Str::limit($bodyText !== '' ? $bodyText : ($subject !== '' ? $subject : 'Email importata da Gmail'), 4000), + 'ticket_id' => (int) $ticket->id, + 'status' => 'linked_to_ticket', + 'received_at' => $this->normalizeDate($date), + 'metadata' => [ + 'gmail_message_id' => $gmailId, + 'gmail_thread_id' => Arr::get($message, 'threadId'), + 'gmail_mailbox_email' => $mailbox['email'] ?? null, + 'google_account_key' => $mailbox['google_account_key'] ?? null, + 'subject' => $subject, + 'to' => $to, + 'documento_id' => (int) $documento->id, + ], + ]); + + $attachments += $this->storeAttachments($token, $gmailId, $ticket, (array) Arr::get($message, 'payload', []), $subject, $openingUserId); + $imported++; + } + + return [ + 'mailbox' => (string) ($mailbox['label'] ?? $mailbox['email'] ?? 'casella-gmail'), + 'imported' => $imported, + 'skipped' => $skipped, + 'attachments' => $attachments, + ]; + } + + /** + * @return array + */ + private function extractHeaders(array $headers): array + { + $result = []; + + foreach ($headers as $header) { + $name = strtolower(trim((string) ($header['name'] ?? ''))); + if ($name === '') { + continue; + } + + $result[$name] = trim((string) ($header['value'] ?? '')); + } + + return $result; + } + + /** + * @param array $payload + */ + private function extractBodyText(array $payload): string + { + $parts = []; + $this->collectTextParts($payload, $parts); + + $plain = trim(implode("\n\n", array_filter($parts))); + if ($plain !== '') { + return Str::limit($plain, 3500); + } + + return trim((string) Arr::get($payload, 'body.data', '')) !== '' + ? Str::limit($this->decodeBase64Url((string) Arr::get($payload, 'body.data', '')), 3500) + : ''; + } + + /** + * @param array $part + * @param array $collector + */ + private function collectTextParts(array $part, array &$collector): void + { + $mimeType = strtolower(trim((string) ($part['mimeType'] ?? ''))); + if ($mimeType === 'text/plain') { + $text = trim($this->decodeBase64Url((string) Arr::get($part, 'body.data', ''))); + if ($text !== '') { + $collector[] = $text; + } + } + + foreach ((array) ($part['parts'] ?? []) as $child) { + if (is_array($child)) { + $this->collectTextParts($child, $collector); + } + } + } + + /** + * @param array $payload + */ + private function storeAttachments(string $token, string $gmailId, Ticket $ticket, array $payload, string $subject, int $userId): int + { + $count = 0; + foreach ($this->flattenAttachmentParts($payload) as $part) { + $fileName = trim((string) ($part['filename'] ?? '')); + if ($fileName === '') { + continue; + } + + $data = trim((string) Arr::get($part, 'body.data', '')); + if ($data === '') { + $attachmentId = trim((string) Arr::get($part, 'body.attachmentId', '')); + if ($attachmentId !== '') { + $response = Http::withToken($token) + ->acceptJson() + ->get('https://gmail.googleapis.com/gmail/v1/users/me/messages/' . rawurlencode($gmailId) . '/attachments/' . rawurlencode($attachmentId)); + + if ($response->successful()) { + $data = trim((string) Arr::get($response->json(), 'data', '')); + } + } + } + + if ($data === '') { + continue; + } + + $binary = $this->decodeBase64Url($data); + if ($binary === '') { + continue; + } + + $stored = $this->ticketAttachmentUploadService->storeRawContents( + $binary, + 'ticket-email/' . $ticket->id, + $fileName, + [ + 'mime' => (string) ($part['mimeType'] ?? ''), + 'optimize_image' => false, + 'display_name' => $fileName, + 'stored_basename' => pathinfo($fileName, PATHINFO_FILENAME), + ] + ); + + TicketAttachment::query()->create([ + 'ticket_id' => (int) $ticket->id, + 'ticket_update_id' => null, + 'user_id' => $userId, + 'file_path' => $stored['path'], + 'original_file_name' => $stored['original_name'], + 'mime_type' => $stored['mime'], + 'size' => $stored['size'], + 'description' => Str::limit('Allegato email importata' . ($subject !== '' ? ': ' . $subject : ''), 255), + ]); + + $count++; + } + + return $count; + } + + /** + * @param array $payload + * @return array> + */ + private function flattenAttachmentParts(array $payload): array + { + $parts = []; + + foreach ((array) ($payload['parts'] ?? []) as $part) { + if (! is_array($part)) { + continue; + } + + if (trim((string) ($part['filename'] ?? '')) !== '') { + $parts[] = $part; + } + + $parts = array_merge($parts, $this->flattenAttachmentParts($part)); + } + + return $parts; + } + + private function alreadyImported(string $externalId, string $gmailId): bool + { + return CommunicationMessage::query() + ->whereIn('external_message_id', array_values(array_filter([$externalId, $gmailId]))) + ->exists(); + } + + /** + * @param array $mailbox + */ + private function buildTicketDescription(Stabile $stabile, array $mailbox, string $subject, string $from, string $to, mixed $date, string $bodyText): string + { + $lines = [ + 'Email importata automaticamente dalla casella stabile.', + 'Stabile: ' . ($stabile->denominazione ?: ('#' . $stabile->id)), + ]; + + $mailboxLabel = trim((string) ($mailbox['label'] ?? '')); + $mailboxEmail = trim((string) ($mailbox['email'] ?? '')); + if ($mailboxLabel !== '' || $mailboxEmail !== '') { + $lines[] = 'Casella: ' . trim($mailboxLabel . ($mailboxEmail !== '' ? ' <' . $mailboxEmail . '>' : '')); + } + + if ($subject !== '') { + $lines[] = 'Oggetto: ' . $subject; + } + if ($from !== '') { + $lines[] = 'Mittente: ' . $from; + } + if ($to !== '') { + $lines[] = 'Destinatario: ' . $to; + } + if ($date) { + $lines[] = 'Data email: ' . (string) $date; + } + if ($bodyText !== '') { + $lines[] = ''; + $lines[] = 'Corpo messaggio:'; + $lines[] = $bodyText; + } + + return trim(implode("\n", $lines)); + } + + private function storeRawEmailDocumento(Ticket $ticket, array $mailbox, string $subject, string $from, string $to, mixed $date, string $rawEml, string $gmailId, int $userId): Documento + { + $fileName = 'gmail-' . $gmailId . '.eml'; + $path = 'documenti/ticket-email/' . $ticket->id . '/' . $fileName; + Storage::disk('public')->put($path, $rawEml); + + return Documento::query()->create([ + 'documentable_id' => $ticket->id, + 'documentable_type' => Ticket::class, + 'stabile_id' => $ticket->stabile_id, + 'utente_id' => $userId, + 'nome_file' => $fileName, + 'path_file' => $path, + 'percorso_file' => $path, + 'tipo_documento' => 'Email EML', + 'tipologia' => 'comunicazione', + 'nome' => $subject !== '' ? $subject : ('Email Gmail ticket #' . $ticket->id), + 'mime_type' => 'message/rfc822', + 'estensione' => 'eml', + 'dimensione_file' => strlen($rawEml), + 'data_documento' => $this->normalizeDate($date), + 'data_upload' => now(), + 'descrizione' => 'Email importata automaticamente da ' . trim((string) ($mailbox['email'] ?? 'Gmail')), + 'tags' => 'email,ticket,gmail,pec', + 'note' => 'Import automatico Gmail per ticket #' . $ticket->id . ' da ' . ($from !== '' ? $from : 'mittente non disponibile'), + ]); + } + + /** + * @return array + */ + private function fetchMessage(string $token, string $gmailId, string $format): array + { + $response = Http::withToken($token) + ->acceptJson() + ->get('https://gmail.googleapis.com/gmail/v1/users/me/messages/' . rawurlencode($gmailId), [ + 'format' => $format, + ]); + + if (! $response->successful()) { + throw new RuntimeException('Impossibile leggere il messaggio Gmail ' . $gmailId . '.'); + } + + return (array) $response->json(); + } + + private function decodeBase64Url(string $value): string + { + $normalized = strtr($value, '-_', '+/'); + $padding = strlen($normalized) % 4; + if ($padding > 0) { + $normalized .= str_repeat('=', 4 - $padding); + } + + $decoded = base64_decode($normalized, true); + + return is_string($decoded) ? $decoded : ''; + } + + private function normalizeDate(mixed $value): mixed + { + $raw = trim((string) $value); + if ($raw === '') { + return null; + } + + try { + return Carbon::parse($raw); + } catch (\Throwable) { + return null; + } + } +} \ No newline at end of file diff --git a/app/Services/Support/TicketAttachmentUploadService.php b/app/Services/Support/TicketAttachmentUploadService.php index 83adf48..da1f5e2 100644 --- a/app/Services/Support/TicketAttachmentUploadService.php +++ b/app/Services/Support/TicketAttachmentUploadService.php @@ -76,6 +76,44 @@ public function store(object $file, string $directory, array $options = []): arr ]; } + /** + * @return array{path:string,mime:string,size:int,original_name:string,source_original_name:string,stored_name:string,metadata:array} + */ + public function storeRawContents(string $contents, string $directory, string $originalName, array $options = []): array + { + $extension = strtolower((string) pathinfo($originalName, PATHINFO_EXTENSION)); + $storedBasename = trim((string) ($options['stored_basename'] ?? '')); + $displayName = trim((string) ($options['display_name'] ?? '')) ?: $originalName; + + $sanitizedBase = trim((string) Str::of(pathinfo($storedBasename !== '' ? $storedBasename : $displayName, PATHINFO_FILENAME))->slug('-'), '-'); + $sanitizedBase = $sanitizedBase !== '' ? $sanitizedBase : 'file'; + $storedName = $sanitizedBase . ($extension !== '' ? '.' . $extension : ''); + $path = trim($directory, '/') . '/' . $storedName; + + Storage::disk('public')->put($path, $contents); + + $mime = TicketAttachment::normalizeMimeType((string) ($options['mime'] ?? ''), $originalName, $path); + $metadata = []; + + if (str_starts_with($mime, 'image/')) { + $metadata = $this->extractImageMetadata($path); + if ($this->shouldOptimizeImage($metadata, $options)) { + $this->optimizeImage($path, $mime, $metadata); + } + $mime = TicketAttachment::normalizeMimeType($mime, $originalName, $path); + } + + return [ + 'path' => $path, + 'mime' => $mime, + 'size' => (int) Storage::disk('public')->size($path), + 'original_name' => $displayName, + 'source_original_name' => $originalName, + 'stored_name' => $storedName, + 'metadata' => $metadata, + ]; + } + private function shouldOptimizeImage(array $metadata, array $options = []): bool { if (array_key_exists('optimize_image', $options)) { @@ -236,7 +274,7 @@ private function extractImageMetadataFromFileObject(object $file): array private function extractImageMetadataFromAbsolutePath(string $absolutePath): array { - $metadata = []; + $metadata = []; $imageSize = @getimagesize($absolutePath); if (is_array($imageSize)) { diff --git a/app/Support/GoogleAccountStore.php b/app/Support/GoogleAccountStore.php new file mode 100644 index 0000000..ff6301f --- /dev/null +++ b/app/Support/GoogleAccountStore.php @@ -0,0 +1,336 @@ +> + */ + public function all(Amministratore $amministratore): array + { + $google = Arr::get($amministratore->impostazioni ?? [], 'google', []); + $accounts = []; + + foreach ((array) Arr::get($google, 'accounts', []) as $rawKey => $account) { + if (! is_array($account)) { + continue; + } + + $key = $this->normalizeAccountKey((string) $rawKey); + if ($key === '') { + $key = $this->normalizeAccountKey( + (string) ($account['email'] ?? $account['google_user_id'] ?? $account['label'] ?? 'google-account') + ); + } + + $accounts[$key] = $this->normalizeAccountPayload($account, $key); + } + + $legacyOauth = Arr::get($google, 'oauth', []); + if (is_array($legacyOauth) && $this->hasUsableOauthPayload($legacyOauth)) { + $legacyKey = $this->normalizeAccountKey( + (string) (Arr::get($google, 'default_account_key') + ?: Arr::get($legacyOauth, 'google_user_id') + ?: Arr::get($legacyOauth, 'email') + ?: 'primary') + ); + + if (! isset($accounts[$legacyKey])) { + $accounts[$legacyKey] = $this->normalizeAccountPayload($legacyOauth, $legacyKey); + } + } + + $defaultKey = $this->normalizeAccountKey((string) Arr::get($google, 'default_account_key', '')); + if ($defaultKey === '' && $accounts !== []) { + $defaultKey = (string) array_key_first($accounts); + } + + foreach ($accounts as $key => $account) { + $accounts[$key]['is_default'] = $key === $defaultKey; + } + + return $accounts; + } + + /** + * @return array + */ + public function options(Amministratore $amministratore): array + { + $options = []; + + foreach ($this->all($amministratore) as $key => $account) { + $label = trim((string) ($account['label'] ?? '')); + $email = trim((string) ($account['email'] ?? '')); + + $text = $label !== '' ? $label : $key; + if ($email !== '') { + $text .= ' (' . $email . ')'; + } + if (! empty($account['is_default'])) { + $text .= ' · predefinito'; + } + + $options[$key] = $text; + } + + return $options; + } + + /** + * @return array|null + */ + public function get(Amministratore $amministratore, ?string $accountKey = null): ?array + { + $accounts = $this->all($amministratore); + if ($accounts === []) { + return null; + } + + $resolvedKey = $this->normalizeAccountKey((string) $accountKey); + if ($resolvedKey !== '' && isset($accounts[$resolvedKey])) { + return $accounts[$resolvedKey]; + } + + foreach ($accounts as $account) { + if (! empty($account['is_default'])) { + return $account; + } + } + + return reset($accounts) ?: null; + } + + public function normalizeAccountKey(?string $value): string + { + $raw = trim((string) $value); + if ($raw === '') { + return ''; + } + + return (string) Str::of($raw)->lower()->replace('@', '-at-')->slug('-'); + } + + /** + * @param array $payload + */ + public function upsertAccount(Amministratore $amministratore, string $accountKey, array $payload, bool $setDefault = false): string + { + $key = $this->normalizeAccountKey($accountKey) ?: 'primary'; + $impostazioni = $amministratore->impostazioni ?? []; + $google = Arr::get($impostazioni, 'google', []); + $accounts = (array) Arr::get($google, 'accounts', []); + $existing = isset($accounts[$key]) && is_array($accounts[$key]) ? $accounts[$key] : []; + $account = $this->normalizeAccountPayload(array_replace($existing, $payload), $key); + + if (empty($account['scopes'])) { + $account['scopes'] = self::SCOPES; + } + + $accounts[$key] = $account; + $google['accounts'] = $accounts; + + $defaultKey = $this->normalizeAccountKey((string) Arr::get($google, 'default_account_key', '')); + if ($setDefault || $defaultKey === '') { + $defaultKey = $key; + } + + $google['default_account_key'] = $defaultKey; + if ($defaultKey === $key) { + $google['oauth'] = $this->toLegacyOauthPayload($account); + if (trim((string) ($account['email'] ?? '')) !== '') { + $google['workspace_email'] = trim((string) $account['email']); + } + } + + $impostazioni['google'] = $google; + $amministratore->impostazioni = $impostazioni; + $amministratore->save(); + + return $key; + } + + public function disconnectAccount(Amministratore $amministratore, ?string $accountKey = null): void + { + $account = $this->get($amministratore, $accountKey); + if (! is_array($account)) { + return; + } + + $key = (string) ($account['key'] ?? 'primary'); + $impostazioni = $amministratore->impostazioni ?? []; + $google = Arr::get($impostazioni, 'google', []); + $accounts = (array) Arr::get($google, 'accounts', []); + $existing = isset($accounts[$key]) && is_array($accounts[$key]) ? $accounts[$key] : $account; + + $accounts[$key] = array_replace($existing, [ + 'connected' => false, + 'access_token' => '', + 'refresh_token' => '', + 'expires_in' => null, + 'refreshed_at' => null, + 'disconnected_at' => now()->toDateTimeString(), + ]); + + $google['accounts'] = $accounts; + if ($this->normalizeAccountKey((string) Arr::get($google, 'default_account_key', '')) === $key) { + $google['oauth'] = [ + 'connected' => false, + 'disconnected_at' => now()->toDateTimeString(), + ]; + } + + $impostazioni['google'] = $google; + $amministratore->impostazioni = $impostazioni; + $amministratore->save(); + } + + public function resolveAccessToken(Amministratore $amministratore, ?string $accountKey = null, bool $forceRefresh = false): ?string + { + $account = $this->get($amministratore, $accountKey); + if (! is_array($account)) { + return null; + } + + $accessToken = trim((string) ($account['access_token'] ?? '')); + $refreshToken = trim((string) ($account['refresh_token'] ?? '')); + + if (! $forceRefresh && $accessToken !== '' && ! $this->isExpired($account)) { + return $accessToken; + } + + if ($refreshToken === '') { + return $accessToken !== '' ? $accessToken : null; + } + + $google = Arr::get($amministratore->impostazioni ?? [], 'google', []); + $clientId = (string) ($google['client_id'] ?? config('services.google.client_id')); + $clientSecret = (string) ($google['client_secret'] ?? config('services.google.client_secret')); + + if ($clientId === '' || $clientSecret === '') { + return $accessToken !== '' ? $accessToken : null; + } + + $response = Http::asForm()->post('https://oauth2.googleapis.com/token', [ + 'client_id' => $clientId, + 'client_secret' => $clientSecret, + 'refresh_token' => $refreshToken, + 'grant_type' => 'refresh_token', + ]); + + if (! $response->successful()) { + return $accessToken !== '' ? $accessToken : null; + } + + $payload = $response->json(); + $newAccessToken = trim((string) Arr::get($payload, 'access_token', '')); + if ($newAccessToken === '') { + return $accessToken !== '' ? $accessToken : null; + } + + $this->upsertAccount($amministratore, (string) ($account['key'] ?? 'primary'), array_filter([ + 'connected' => true, + 'label' => $account['label'] ?? null, + 'email' => $account['email'] ?? null, + 'name' => $account['name'] ?? null, + 'google_user_id'=> $account['google_user_id'] ?? null, + 'access_token' => $newAccessToken, + 'refresh_token' => trim((string) Arr::get($payload, 'refresh_token', '')) ?: $refreshToken, + 'expires_in' => (int) Arr::get($payload, 'expires_in', $account['expires_in'] ?? 3600), + 'refreshed_at' => now()->toDateTimeString(), + 'scopes' => $account['scopes'] ?? self::SCOPES, + ], static fn ($value) => $value !== null), ! empty($account['is_default'])); + + return $newAccessToken; + } + + /** + * @param array $account + * @return array + */ + private function normalizeAccountPayload(array $account, string $key): array + { + $label = trim((string) ($account['label'] ?? $account['name'] ?? $account['email'] ?? $key)); + + return [ + 'key' => $key, + 'label' => $label, + 'connected' => (bool) ($account['connected'] ?? false), + 'email' => trim((string) ($account['email'] ?? '')), + 'name' => trim((string) ($account['name'] ?? '')), + 'google_user_id' => trim((string) ($account['google_user_id'] ?? '')), + 'access_token' => trim((string) ($account['access_token'] ?? '')), + 'refresh_token' => trim((string) ($account['refresh_token'] ?? '')), + 'expires_in' => (int) ($account['expires_in'] ?? 0), + 'connected_at' => $account['connected_at'] ?? null, + 'refreshed_at' => $account['refreshed_at'] ?? null, + 'disconnected_at'=> $account['disconnected_at'] ?? null, + 'scopes' => array_values(array_filter((array) ($account['scopes'] ?? []))), + ]; + } + + /** + * @param array $account + * @return array + */ + private function toLegacyOauthPayload(array $account): array + { + return [ + 'connected' => (bool) ($account['connected'] ?? false), + 'email' => (string) ($account['email'] ?? ''), + 'name' => (string) ($account['name'] ?? ''), + 'google_user_id' => (string) ($account['google_user_id'] ?? ''), + 'access_token' => (string) ($account['access_token'] ?? ''), + 'refresh_token' => (string) ($account['refresh_token'] ?? ''), + 'expires_in' => (int) ($account['expires_in'] ?? 0), + 'connected_at' => $account['connected_at'] ?? null, + 'refreshed_at' => $account['refreshed_at'] ?? null, + 'scopes' => $account['scopes'] ?? self::SCOPES, + ]; + } + + /** + * @param array $account + */ + private function isExpired(array $account): bool + { + $refreshedAt = trim((string) ($account['refreshed_at'] ?? $account['connected_at'] ?? '')); + $expiresIn = (int) ($account['expires_in'] ?? 0); + if ($refreshedAt === '' || $expiresIn <= 0) { + return false; + } + + $timestamp = strtotime($refreshedAt); + if ($timestamp === false) { + return false; + } + + return ($timestamp + max(60, $expiresIn - 120)) <= time(); + } + + /** + * @param array $oauth + */ + private function hasUsableOauthPayload(array $oauth): bool + { + return (bool) ($oauth['connected'] ?? false) + || trim((string) ($oauth['refresh_token'] ?? '')) !== '' + || trim((string) ($oauth['access_token'] ?? '')) !== ''; + } +} \ No newline at end of file diff --git a/resources/css/filament/admin/theme.css b/resources/css/filament/admin/theme.css index 57d948f..4d8298d 100644 --- a/resources/css/filament/admin/theme.css +++ b/resources/css/filament/admin/theme.css @@ -39,14 +39,27 @@ .netgescon-footer { .fi-sidebar-nav, .fi-sidebar-nav-groups { - row-gap: calc(var(--spacing) * 4); + row-gap: calc(var(--spacing) * 1.5) !important; } .fi-sidebar-group-btn, .fi-sidebar-group-dropdown-trigger-btn { - padding-block: calc(var(--spacing) * 1.25); + min-height: auto !important; + padding-block: calc(var(--spacing) * 0.55) !important; } .fi-sidebar-group-label { - line-height: calc(var(--spacing) * 5); + line-height: 1.2 !important; +} + +.fi-sidebar-item-button, +.fi-sidebar-item a { + min-height: auto !important; + padding-block: calc(var(--spacing) * 0.45) !important; +} + +.fi-sidebar-nav-groups>li, +.fi-sidebar-group, +.fi-sidebar-item { + margin-block: 0 !important; } \ No newline at end of file diff --git a/resources/views/filament/components/legacy-admin-assets.blade.php b/resources/views/filament/components/legacy-admin-assets.blade.php index ed9722c..12b954f 100644 --- a/resources/views/filament/components/legacy-admin-assets.blade.php +++ b/resources/views/filament/components/legacy-admin-assets.blade.php @@ -2,3 +2,31 @@ + diff --git a/resources/views/filament/pages/condomini/stabile-posta.blade.php b/resources/views/filament/pages/condomini/stabile-posta.blade.php index ee542ce..5686dc5 100644 --- a/resources/views/filament/pages/condomini/stabile-posta.blade.php +++ b/resources/views/filament/pages/condomini/stabile-posta.blade.php @@ -14,6 +14,41 @@ +
+
+
+
Account Google disponibili per posta stabile
+
Collega qui un account di prova e un account dedicato allo stabile. Dopo il collegamento lo puoi selezionare nella casella Gmail qui sotto.
+
+ +
+ + @if(count($this->getGoogleAccountsSummary()) > 0) +
+ @foreach($this->getGoogleAccountsSummary() as $account) +
+
+
+
{{ $account['label'] ?: $account['key'] }}
+
{{ $account['email'] ?: 'Email non disponibile' }}
+
{{ !empty($account['connected']) ? 'Collegato' : 'Non collegato' }}@if(!empty($account['is_default'])) · predefinito @endif
+
+
+ @csrf + + + +
+
+
+ @endforeach +
+ @endif +
+
{{ $this->getSchema('form') }} diff --git a/resources/views/filament/pages/supporto/ticket-mobile.blade.php b/resources/views/filament/pages/supporto/ticket-mobile.blade.php index f70d922..c5f451b 100644 --- a/resources/views/filament/pages/supporto/ticket-mobile.blade.php +++ b/resources/views/filament/pages/supporto/ticket-mobile.blade.php @@ -362,9 +362,9 @@
iPhone: su "Aggiungi allegati" scegli "Sfoglia" per aprire l'app File (iCloud Drive/On My iPhone).
Caricamento in corso: sto accodando foto e allegati alla bozza ticket.
-
+
Anteprima immediata sul telefono
-
Le foto e gli allegati selezionati si vedono subito qui mentre entrano in coda. Nella griglia sotto ogni miniatura resta nello stesso box della sua descrizione.
+
Questa anteprima resta visibile solo finche i file non entrano nella coda operativa. Appena sono accodati, miniatura e descrizione restano nello stesso box qui sotto.