> */ public array $calendarEvents = []; /** @var array> */ public array $contacts = []; /** @var array> */ public array $openTickets = []; /** @var array */ public array $driveTemplateFolders = []; public ?string $errorMessage = null; public bool $showTechnicalBoxes = true; public ?string $rubricaSyncCommandPreview = null; public ?string $rubricaSyncLastOutput = null; public ?string $rubricaSyncLastRunLabel = null; public function mount(): void { $amministratore = $this->resolveAmministratore(); if (! $amministratore instanceof Amministratore) { $this->errorMessage = 'Amministratore non disponibile nella sessione corrente.'; return; } $google = Arr::get($amministratore->impostazioni ?? [], 'google', []); $oauth = Arr::get($google, 'oauth', []); $this->googleConnected = (bool) Arr::get($oauth, 'connected', false); $email = (string) Arr::get($oauth, 'email', ''); $this->connectionLabel = $email !== '' ? $email : ($this->googleConnected ? 'Collegato' : 'Non collegato'); $this->calendarId = (string) Arr::get($google, 'calendar_id', 'primary') ?: 'primary'; $this->rubricaSyncCommandPreview = 'php artisan google:sync-rubrica --admin-id=' . (int) $amministratore->id . ' --max-pages=20'; if (! $this->googleConnected) { $this->errorMessage = 'Google non e collegato. Vai su Scheda amministratore per completare il collegamento.'; return; } $token = $this->resolveAccessToken($amministratore, $google, $oauth); if ($token === null) { return; } $this->loadCalendarEvents($token, $this->calendarId); $this->loadContactsPreview($token); $this->loadOpenTickets(); $this->loadDriveTemplateFolders(); } public function getCalendarioUrl(): string { return Calendario::getUrl(panel: 'admin-filament'); } public function getRubricaUrl(): string { return RubricaUniversaleArchivio::getUrl(panel: 'admin-filament'); } public function getTicketApertiUrl(): string { return TicketMobile::getUrl(panel: 'admin-filament'); } public function getTicketGestioneUrl(int $ticketId): string { return TicketGestione::getUrl(['ticket' => $ticketId, 'tab' => 'scheda'], panel: 'admin-filament'); } public function previewImportContactsToRubrica(): void { $this->runRubricaSync(true); } public function importContactsToRubrica(): void { $this->runRubricaSync(false); } private function runRubricaSync(bool $dryRun): void { $amministratore = $this->resolveAmministratore(); if (! $amministratore instanceof Amministratore) { Notification::make() ->title('Import rubrica non disponibile') ->warning() ->body('Amministratore non disponibile nella sessione corrente.') ->send(); return; } $google = Arr::get($amministratore->impostazioni ?? [], 'google', []); $oauth = Arr::get($google, 'oauth', []); $token = $this->resolveAccessToken($amministratore, $google, $oauth); if ($token === null) { Notification::make() ->title($dryRun ? 'Preview rubrica non disponibile' : 'Import rubrica non riuscito') ->warning() ->body($this->errorMessage ?? 'Token Google non disponibile.') ->send(); return; } $params = [ '--admin-id' => [(string) $amministratore->id], '--max-pages' => 20, ]; if ($dryRun) { $params['--dry-run'] = true; } $exit = Artisan::call('google:sync-rubrica', $params); $output = trim((string) Artisan::output()); $this->rubricaSyncLastRunLabel = now()->format('d/m/Y H:i:s') . ($dryRun ? ' · dry-run' : ' · apply'); $this->rubricaSyncLastOutput = $output !== '' ? $output : 'Nessun output disponibile.'; $this->rubricaSyncCommandPreview = 'php artisan google:sync-rubrica --admin-id=' . (int) $amministratore->id . ' --max-pages=20' . ($dryRun ? ' --dry-run' : ''); if (! $dryRun) { $this->loadContactsPreview($token); } Notification::make() ->title($exit === 0 ? ($dryRun ? 'Preview import rubrica completata' : 'Import rubrica completato') : ($dryRun ? 'Preview import rubrica con errori' : 'Import rubrica con errori')) ->body((string) collect(preg_split('/\R/', $this->rubricaSyncLastOutput) ?: [])->filter()->last()) ->{$exit === 0 ? 'success' : 'warning'}() ->send(); } public function exportRubricaToGoogle(): void { $amministratore = $this->resolveAmministratore(); if (! $amministratore instanceof Amministratore) { Notification::make() ->title('Export Google non disponibile') ->warning() ->body('Amministratore non disponibile nella sessione corrente.') ->send(); return; } $google = Arr::get($amministratore->impostazioni ?? [], 'google', []); $oauth = Arr::get($google, 'oauth', []); $token = $this->resolveAccessToken($amministratore, $google, $oauth); if ($token === null) { Notification::make() ->title('Export Google non riuscito') ->warning() ->body($this->errorMessage ?? 'Token Google non disponibile.') ->send(); return; } $existingEmails = []; $existingPhones = []; $nextPageToken = null; for ($page = 0; $page < 5; $page++) { $response = Http::withToken($token) ->acceptJson() ->get('https://people.googleapis.com/v1/people/me/connections', [ 'personFields' => 'emailAddresses,phoneNumbers', 'pageSize' => 500, 'pageToken' => $nextPageToken, ]); if ($response->status() === 401) { $token = $this->resolveAccessToken($amministratore, $google, $oauth, true) ?? $token; $response = Http::withToken($token) ->acceptJson() ->get('https://people.googleapis.com/v1/people/me/connections', [ 'personFields' => 'emailAddresses,phoneNumbers', 'pageSize' => 500, 'pageToken' => $nextPageToken, ]); } if (! $response->successful()) { Notification::make() ->title('Export Google interrotto') ->warning() ->body($this->googleErrorBody($response, 'Impossibile leggere i contatti Google esistenti.')) ->send(); return; } $connections = $response->json('connections'); if (! is_array($connections) || count($connections) === 0) { break; } foreach ($connections as $person) { $email = trim((string) Arr::get($person, 'emailAddresses.0.value', '')); $phone = trim((string) Arr::get($person, 'phoneNumbers.0.value', '')); if ($email !== '') { $existingEmails[strtolower($email)] = true; } if ($phone !== '') { $normalized = $this->normalizePhone($phone); if ($normalized !== '') { $existingPhones[$normalized] = true; } } } $nextPageToken = $response->json('nextPageToken'); if (! is_string($nextPageToken) || $nextPageToken === '') { break; } } $records = RubricaUniversale::query() ->where(function ($q): void { $q->whereNotNull('email')->orWhereNotNull('telefono_cellulare'); }) ->where('stato', 'attivo') ->orderByDesc('updated_at') ->limit(300) ->get(); $created = 0; $skipped = 0; $errors = 0; foreach ($records as $record) { $email = trim((string) ($record->email ?? '')); $phone = trim((string) ($record->telefono_cellulare ?? '')); $phoneNormalized = $this->normalizePhone($phone); if ( ($email !== '' && isset($existingEmails[strtolower($email)])) || ($phoneNormalized !== '' && isset($existingPhones[$phoneNormalized])) ) { $skipped++; continue; } [$givenName, $familyName, $fullName] = $this->buildContactNameParts( (string) ($record->nome ?? ''), (string) ($record->cognome ?? ''), (string) ($record->ragione_sociale ?? '') ); $payload = [ 'names' => [ [ 'displayName' => $fullName, 'givenName' => $givenName, 'familyName' => $familyName, ], ], ]; if ($email !== '') { $payload['emailAddresses'] = [['value' => $email]]; } if ($phone !== '') { $payload['phoneNumbers'] = [['value' => $phone]]; } $createResponse = Http::withToken($token) ->acceptJson() ->post('https://people.googleapis.com/v1/people:createContact', $payload); if ($createResponse->status() === 401) { $token = $this->resolveAccessToken($amministratore, $google, $oauth, true) ?? $token; $createResponse = Http::withToken($token) ->acceptJson() ->post('https://people.googleapis.com/v1/people:createContact', $payload); } if (! $createResponse->successful()) { $errors++; continue; } $created++; if ($email !== '') { $existingEmails[strtolower($email)] = true; } if ($phoneNormalized !== '') { $existingPhones[$phoneNormalized] = true; } } $this->loadContactsPreview($token); Notification::make() ->title('Export Rubrica -> Google completato') ->success() ->body("Creati {$created} contatti, saltati {$skipped}, errori {$errors}.") ->send(); } public function provaSincronizzazioneCalendarioTicket(): void { $this->sincronizzaTicketSuGoogleCalendar(true); } public function sincronizzaCalendarioTicket(): void { $this->sincronizzaTicketSuGoogleCalendar(false); } private function resolveAccessToken(Amministratore $amministratore, array $google, array $oauth, bool $forceRefresh = false): ?string { $accessToken = trim((string) Arr::get($oauth, 'access_token', '')); $refreshToken = trim((string) Arr::get($oauth, 'refresh_token', '')); if (! $forceRefresh && $accessToken !== '') { return $accessToken; } if ($refreshToken === '') { $this->errorMessage = 'Token Google non disponibile. Ricollega l account Google.'; return null; } $settingsClientId = trim((string) Arr::get($google, 'client_id', '')); $settingsClientSecret = trim((string) Arr::get($google, 'client_secret', '')); $configClientId = trim((string) config('services.google.client_id')); $configClientSecret = trim((string) config('services.google.client_secret')); $credentialPairs = []; if ($settingsClientId !== '' && $settingsClientSecret !== '') { $credentialPairs[] = [$settingsClientId, $settingsClientSecret, 'settings']; } if ( $configClientId !== '' && $configClientSecret !== '' && ($configClientId !== $settingsClientId || $configClientSecret !== $settingsClientSecret) ) { $credentialPairs[] = [$configClientId, $configClientSecret, 'config']; } if ($credentialPairs === []) { $this->errorMessage = 'Client ID/Secret Google mancanti. Controlla Scheda amministratore.'; return null; } $payload = null; $lastError = null; foreach ($credentialPairs as [$clientId, $clientSecret, $source]) { $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()) { $payload = $response->json(); break; } $message = (string) (Arr::get($response->json(), 'error_description') ?: Arr::get($response->json(), 'error.message') ?: 'refresh_failed'); $lastError = "{$source}:{$response->status()} {$message}"; } if (! is_array($payload)) { $this->errorMessage = 'Refresh token Google non riuscito: ' . $lastError; return null; } $newAccessToken = trim((string) Arr::get($payload, 'access_token', '')); if ($newAccessToken === '') { $this->errorMessage = 'Google ha risposto senza access token valido.'; return null; } $impostazioni = $amministratore->impostazioni ?? []; $impostazioniGoogle = Arr::get($impostazioni, 'google', []); $impostazioniOauth = Arr::get($impostazioniGoogle, 'oauth', []); $impostazioniOauth['access_token'] = $newAccessToken; $impostazioniOauth['expires_in'] = (int) Arr::get($payload, 'expires_in', 3600); $impostazioniOauth['refreshed_at'] = Carbon::now()->toDateTimeString(); if (isset($payload['refresh_token']) && is_string($payload['refresh_token']) && $payload['refresh_token'] !== '') { $impostazioniOauth['refresh_token'] = $payload['refresh_token']; } $impostazioniGoogle['oauth'] = $impostazioniOauth; $impostazioni['google'] = $impostazioniGoogle; $amministratore->impostazioni = $impostazioni; $amministratore->save(); return $newAccessToken; } private function loadCalendarEvents(string $token, string $calendarId): void { $response = Http::withToken($token) ->acceptJson() ->get('https://www.googleapis.com/calendar/v3/calendars/' . rawurlencode($calendarId) . '/events', [ 'singleEvents' => 'true', 'orderBy' => 'startTime', 'timeMin' => Carbon::now()->toIso8601String(), 'maxResults' => 8, ]); if (! $response->successful() && $calendarId !== 'primary') { $this->calendarId = 'primary'; $response = Http::withToken($token) ->acceptJson() ->get('https://www.googleapis.com/calendar/v3/calendars/primary/events', [ 'singleEvents' => 'true', 'orderBy' => 'startTime', 'timeMin' => Carbon::now()->toIso8601String(), 'maxResults' => 8, ]); } if (! $response->successful()) { $this->errorMessage = 'Calendario Google non disponibile al momento. Verifica calendar_id e permessi OAuth.'; return; } $items = $response->json('items'); if (! is_array($items)) { return; } $events = []; foreach ($items as $item) { $start = (string) (Arr::get($item, 'start.dateTime') ?: Arr::get($item, 'start.date') ?: ''); $summary = (string) Arr::get($item, 'summary', 'Evento senza titolo'); $location = (string) Arr::get($item, 'location', ''); $events[] = [ 'title' => $summary, 'start' => $start, 'location' => $location, ]; } $this->calendarEvents = $events; } private function sincronizzaTicketSuGoogleCalendar(bool $dryRun): void { $amministratore = $this->resolveAmministratore(); if (! $amministratore instanceof Amministratore) { Notification::make()->title('Sync calendario non disponibile')->warning()->send(); return; } $google = Arr::get($amministratore->impostazioni ?? [], 'google', []); $oauth = Arr::get($google, 'oauth', []); $token = $this->resolveAccessToken($amministratore, $google, $oauth); if ($token === null) { Notification::make()->title('Sync calendario non riuscito')->warning()->body($this->errorMessage ?? 'Token Google non disponibile.')->send(); return; } $stabile = StabileContext::getActiveStabile(Auth::user()); if (! $stabile instanceof Stabile) { Notification::make()->title('Sync calendario non riuscito')->warning()->body('Seleziona prima uno stabile attivo.')->send(); return; } $tickets = Ticket::query() ->where('stabile_id', $stabile->id) ->whereIn('stato', ['Aperto', 'Preso in Carico', 'In Lavorazione']) ->orderByDesc('created_at') ->limit(30) ->get(); $created = 0; $updated = 0; $errors = 0; $calendarId = (string) ($this->calendarId ?: 'primary'); foreach ($tickets as $ticket) { $payload = $this->buildCalendarEventPayload($ticket, (string) ($stabile->denominazione ?? ('Stabile #' . $stabile->id))); $event = $this->findCalendarEventByTicketId($token, $calendarId, (int) $ticket->id); if ($event === null) { if ($dryRun) { $created++; continue; } $createResponse = Http::withToken($token) ->acceptJson() ->post('https://www.googleapis.com/calendar/v3/calendars/' . rawurlencode($calendarId) . '/events', $payload); if (! $createResponse->successful()) { $errors++; continue; } $created++; continue; } if ($dryRun) { $updated++; continue; } $eventId = trim((string) Arr::get($event, 'id', '')); if ($eventId === '') { $errors++; continue; } $updateResponse = Http::withToken($token) ->acceptJson() ->patch('https://www.googleapis.com/calendar/v3/calendars/' . rawurlencode($calendarId) . '/events/' . rawurlencode($eventId), $payload); if (! $updateResponse->successful()) { $errors++; continue; } $updated++; } $mode = $dryRun ? 'Prova sync' : 'Sync calendario'; Notification::make() ->title($mode . ' completata') ->success() ->body("Creazioni: {$created}, aggiornamenti: {$updated}, errori: {$errors}.") ->send(); if (! $dryRun) { $this->loadCalendarEvents($token, $calendarId); } } private function findCalendarEventByTicketId(string $token, string $calendarId, int $ticketId): ?array { $response = Http::withToken($token) ->acceptJson() ->get('https://www.googleapis.com/calendar/v3/calendars/' . rawurlencode($calendarId) . '/events', [ 'privateExtendedProperty' => 'netgescon_ticket_id=' . $ticketId, 'maxResults' => 1, ]); if (! $response->successful()) { return null; } $items = $response->json('items'); if (! is_array($items) || count($items) === 0) { return null; } $first = $items[0]; return is_array($first) ? $first : null; } private function buildCalendarEventPayload(Ticket $ticket, string $stabileName): array { $startAt = Carbon::parse($ticket->data_apertura ?? $ticket->created_at ?? now()); $endAt = (clone $startAt)->addMinutes(30); return [ 'summary' => 'Ticket #' . (int) $ticket->id . ' - ' . (string) ($ticket->titolo ?? 'Segnalazione'), 'description' => implode("\n", [ 'Stabile: ' . $stabileName, 'Stato: ' . (string) ($ticket->stato ?? '-'), 'Priorita: ' . (string) ($ticket->priorita ?? '-'), 'Luogo: ' . (string) ($ticket->luogo_intervento ?? '-'), '', (string) ($ticket->descrizione ?? ''), ]), 'start' => [ 'dateTime' => $startAt->toIso8601String(), 'timeZone' => 'Europe/Rome', ], 'end' => [ 'dateTime' => $endAt->toIso8601String(), 'timeZone' => 'Europe/Rome', ], 'extendedProperties' => [ 'private' => [ 'netgescon_ticket_id' => (string) $ticket->id, ], ], ]; } private function loadContactsPreview(string $token): void { $response = Http::withToken($token) ->acceptJson() ->get('https://people.googleapis.com/v1/people/me/connections', [ 'personFields' => 'names,emailAddresses,phoneNumbers', 'pageSize' => 8, ]); if (! $response->successful()) { return; } $connections = $response->json('connections'); if (! is_array($connections)) { return; } $contacts = []; foreach ($connections as $person) { $name = (string) Arr::get($person, 'names.0.displayName', 'Contatto'); $email = (string) Arr::get($person, 'emailAddresses.0.value', ''); $phone = (string) Arr::get($person, 'phoneNumbers.0.value', ''); $contacts[] = [ 'name' => $name, 'email' => $email, 'phone' => $phone, ]; } $this->contacts = $contacts; } private function loadOpenTickets(): void { $user = Auth::user(); if (! $user instanceof User) { $this->openTickets = []; return; } $stabileId = StabileContext::resolveActiveStabileId($user); if (! $stabileId) { $this->openTickets = []; return; } $items = Ticket::query() ->with('stabile:id,denominazione') ->where('stabile_id', $stabileId) ->aperti() ->orderByDesc('created_at') ->limit(8) ->get(); $tickets = []; foreach ($items as $ticket) { $tickets[] = [ 'id' => (int) $ticket->id, 'titolo' => (string) ($ticket->titolo ?? 'Ticket senza titolo'), 'stato' => (string) ($ticket->stato ?? '-'), 'priorita' => (string) ($ticket->priorita ?? '-'), 'stabile' => (string) ($ticket->stabile->denominazione ?? 'N/A'), ]; } $this->openTickets = $tickets; } private function normalizePhone(string $phone): string { return preg_replace('/[^0-9+]/', '', trim($phone)) ?? ''; } /** * @return array{0:string,1:string,2:string} */ private function buildContactNameParts(string $nomeRaw, string $cognomeRaw, string $ragioneSociale): array { $nome = trim($nomeRaw); $cognome = trim($cognomeRaw); if ($cognome === '' && str_contains($nome, ' ')) { $parts = preg_split('/\s+/', $nome) ?: []; if (count($parts) > 1) { $nome = trim((string) array_shift($parts)); $cognome = trim(implode(' ', $parts)); } } $display = trim($nome . ' ' . $cognome); if ($display === '') { $display = trim($ragioneSociale); } if ($display === '') { $display = 'Contatto NetGescon'; } return [$nome, $cognome, $display]; } private function loadDriveTemplateFolders(): void { $items = config('netgescon.google.drive_template_folders', []); $this->driveTemplateFolders = is_array($items) ? array_values(array_filter(array_map('strval', $items), fn(string $v) : bool => trim($v) !== '')) : []; } private function googleErrorBody($response, string $fallback): string { try { $status = (int) $response->status(); $message = (string) Arr::get($response->json(), 'error.message', ''); if ($message !== '') { return $fallback . " (HTTP {$status}: {$message})"; } return $fallback . " (HTTP {$status})"; } catch (\Throwable) { return $fallback; } } private function resolveAmministratore(): ?Amministratore { $user = Auth::user(); if (! $user instanceof User) { return null; } $amministratore = $user->amministratore; if (! $amministratore instanceof Amministratore && $user->hasAnyRole(['super-admin', 'admin'])) { $stabile = StabileContext::getActiveStabile($user); if ($stabile instanceof Stabile && $stabile->amministratore instanceof Amministratore) { $amministratore = $stabile->amministratore; } } return $amministratore instanceof Amministratore ? $amministratore : null; } /** * @return array{0:string,1:string} */ private function splitDisplayName(string $displayName, string $fallbackEmail): array { $displayName = trim($displayName); if ($displayName === '') { $local = trim((string) strtok($fallbackEmail, '@')); return [$local !== '' ? $local : 'Contatto', '']; } $parts = preg_split('/\s+/', $displayName) ?: []; if (count($parts) <= 1) { return [$parts[0] ?? $displayName, '']; } $nome = array_shift($parts) ?: ''; $cognome = trim(implode(' ', $parts)); return [$nome, $cognome]; } private function inferCategoria(string $displayName, string $email): string { $haystack = strtolower(trim($displayName . ' ' . $email)); if ($haystack === '') { return 'altro'; } if (str_contains($haystack, 'assicur') || str_contains($haystack, 'insurance')) { return 'assicurazione'; } if (str_contains($haystack, 'banca') || str_contains($haystack, 'bank') || str_contains($haystack, 'unicredit') || str_contains($haystack, 'intesa')) { return 'banca'; } if (str_contains($haystack, 'condominio') || str_contains($haystack, 'condom')) { return 'condomino'; } if ( str_contains($haystack, 'srl') || str_contains($haystack, 'spa') || str_contains($haystack, 'sas') || str_contains($haystack, 'snc') || str_contains($haystack, 'impianti') || str_contains($haystack, 'servizi') || str_contains($haystack, 'edil') || str_contains($haystack, 'idraul') || str_contains($haystack, 'elettric') ) { return 'fornitore'; } return 'cliente'; } private function inferTipoContatto(string $displayName, string $email): string { $haystack = strtolower(trim($displayName . ' ' . $email)); if ( str_contains($haystack, 'srl') || str_contains($haystack, 'spa') || str_contains($haystack, 'sas') || str_contains($haystack, 'snc') || str_contains($haystack, 'stp') || str_contains($haystack, 'studio') || str_contains($haystack, 'impresa') ) { return 'persona_giuridica'; } return 'persona_fisica'; } }