netgescon-day0/app/Console/Commands/GooglePushRubricaContactsCommand.php

565 lines
22 KiB
PHP
Executable File

<?php
namespace App\Console\Commands;
use App\Models\Amministratore;
use App\Models\RubricaContattoCanale;
use App\Models\RubricaUniversale;
use App\Support\PhoneNumber;
use Illuminate\Console\Command;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Schema;
class GooglePushRubricaContactsCommand extends Command
{
protected $signature = 'google:push-rubrica
{--admin-id=* : Limita la push a uno o piu ID amministratore}
{--limit=300 : Numero massimo di contatti NetGescon da valutare per admin}
{--categoria=* : Filtra per categoria rubrica (es: fornitore, condomino)}
{--dry-run : Simula senza scrivere su Google}';
protected $description = 'Sincronizza contatti NetGescon verso Google Contacts (NetGescon -> Google).';
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
$limit = max(1, (int) $this->option('limit'));
$categorie = collect((array) $this->option('categoria'))
->map(fn($v) => trim((string) $v))
->filter(fn(string $v) => $v !== '')
->values()
->all();
$filterAdminIds = collect((array) $this->option('admin-id'))
->map(fn($v) => (int) $v)
->filter(fn(int $v) => $v > 0)
->values()
->all();
$query = Amministratore::query();
if ($filterAdminIds !== []) {
$query->whereIn('id', $filterAdminIds);
}
$amministratori = $query->get();
if ($amministratori->isEmpty()) {
$this->warn('Nessun amministratore trovato per i filtri richiesti.');
return self::SUCCESS;
}
$totalCreated = 0;
$totalUpdated = 0;
$totalSkipped = 0;
$totalErrors = 0;
foreach ($amministratori as $amministratore) {
$google = Arr::get($amministratore->impostazioni ?? [], 'google', []);
$oauth = Arr::get($google, 'oauth', []);
if (! (bool) Arr::get($oauth, 'connected', false)) {
$this->line("Admin {$amministratore->id}: Google non collegato, salto.");
continue;
}
$token = $this->resolveAccessToken($amministratore, $google, $oauth, $dryRun);
if ($token === null) {
$this->warn("Admin {$amministratore->id}: token non disponibile, salto.");
continue;
}
[$existingEmails, $existingPhones, $token] = $this->loadGoogleContactIndex($amministratore, $google, $oauth, $token, $dryRun);
$records = RubricaUniversale::query()
->where(function ($q): void {
$q->whereNotNull('email')->orWhereNotNull('telefono_cellulare');
})
->where('stato', 'attivo')
->when($categorie !== [], function ($q) use ($categorie): void {
$q->whereIn('categoria', $categorie);
})
->orderByDesc('updated_at')
->limit($limit)
->get();
$created = 0;
$updated = 0;
$skipped = 0;
$errors = 0;
foreach ($records as $record) {
$emails = $this->emailsForGoogle($record);
$phones = $this->phonesForGoogle($record);
$email = $emails[0]['value'] ?? '';
$phone = $phones[0]['value'] ?? '';
$phoneNormalized = $this->normalizePhone($phone);
[$givenName, $familyName, $fullName] = $this->buildGoogleNameParts($record);
$payload = [
'names' => [[
'displayName' => $fullName,
'givenName' => $givenName,
'familyName' => $familyName,
]],
];
if ($emails !== []) {
$payload['emailAddresses'] = $emails;
}
if ($phones !== []) {
$payload['phoneNumbers'] = $phones;
}
$resourceName = null;
if (! empty($record->google_person_resource_name)) {
$resourceName = (string) $record->google_person_resource_name;
} elseif ($email !== '' && isset($existingEmails[strtolower($email)])) {
$resourceName = $existingEmails[strtolower($email)];
} elseif ($phoneNormalized !== '' && isset($existingPhones[$phoneNormalized])) {
$resourceName = $existingPhones[$phoneNormalized];
}
if ($dryRun) {
if ($resourceName !== null) {
$updated++;
} elseif ($email === '' && $phoneNormalized === '') {
$skipped++;
} else {
$created++;
}
if ($email !== '') {
$existingEmails[strtolower($email)] = $resourceName ?? 'dry-run';
}
if ($phoneNormalized !== '') {
$existingPhones[$phoneNormalized] = $resourceName ?? 'dry-run';
}
continue;
}
if ($resourceName !== null) {
$personResponse = Http::withToken($token)
->acceptJson()
->get("https://people.googleapis.com/v1/{$resourceName}", [
'personFields' => 'metadata',
]);
if ($personResponse->status() === 401) {
$token = $this->resolveAccessToken($amministratore, $google, $oauth, false, true) ?? $token;
$personResponse = Http::withToken($token)
->acceptJson()
->get("https://people.googleapis.com/v1/{$resourceName}", [
'personFields' => 'metadata',
]);
}
if (! $personResponse->successful()) {
$errors++;
continue;
}
$etag = (string) Arr::get($personResponse->json(), 'etag', '');
if ($etag === '') {
$etag = (string) Arr::get($personResponse->json(), 'metadata.sources.0.etag', '');
}
$updatePayload = $payload;
$updatePayload['resourceName'] = $resourceName;
if ($etag !== '') {
$updatePayload['etag'] = $etag;
}
$updateResponse = Http::withToken($token)
->acceptJson()
->patch("https://people.googleapis.com/v1/{$resourceName}:updateContact?updatePersonFields=names,emailAddresses,phoneNumbers", $updatePayload);
if ($updateResponse->status() === 401) {
$token = $this->resolveAccessToken($amministratore, $google, $oauth, false, true) ?? $token;
$updateResponse = Http::withToken($token)
->acceptJson()
->patch("https://people.googleapis.com/v1/{$resourceName}:updateContact?updatePersonFields=names,emailAddresses,phoneNumbers", $updatePayload);
}
if (! $updateResponse->successful()) {
$errors++;
continue;
}
if ($record->google_person_resource_name !== $resourceName) {
$record->google_person_resource_name = $resourceName;
$record->save();
}
$updated++;
if ($email !== '') {
$existingEmails[strtolower($email)] = $resourceName;
}
if ($phoneNormalized !== '') {
$existingPhones[$phoneNormalized] = $resourceName;
}
continue;
}
$createResponse = Http::withToken($token)
->acceptJson()
->post('https://people.googleapis.com/v1/people:createContact', $payload);
if ($createResponse->status() === 401) {
$token = $this->resolveAccessToken($amministratore, $google, $oauth, false, true) ?? $token;
$createResponse = Http::withToken($token)
->acceptJson()
->post('https://people.googleapis.com/v1/people:createContact', $payload);
}
if (! $createResponse->successful()) {
$errors++;
continue;
}
$created++;
$createdResource = (string) Arr::get($createResponse->json(), 'resourceName', '');
if ($createdResource !== '' && $record->google_person_resource_name !== $createdResource) {
$record->google_person_resource_name = $createdResource;
$record->save();
}
if ($email !== '') {
$existingEmails[strtolower($email)] = $createdResource !== '' ? $createdResource : 'created';
}
if ($phoneNormalized !== '') {
$existingPhones[$phoneNormalized] = $createdResource !== '' ? $createdResource : 'created';
}
}
$totalCreated += $created;
$totalUpdated += $updated;
$totalSkipped += $skipped;
$totalErrors += $errors;
$mode = $dryRun ? 'dry-run' : 'apply';
$this->info("Admin {$amministratore->id} ({$mode}): creati {$created}, aggiornati {$updated}, saltati {$skipped}, errori {$errors}.");
}
$this->newLine();
$this->info("Totale push: creati {$totalCreated}, aggiornati {$totalUpdated}, saltati {$totalSkipped}, errori {$totalErrors}.");
return self::SUCCESS;
}
/**
* @return array{0: array<string,string>, 1: array<string,string>, 2: string}
*/
private function loadGoogleContactIndex(Amministratore $amministratore, array $google, array $oauth, string $token, bool $dryRun): array
{
$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, $dryRun, 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()) {
break;
}
$connections = $response->json('connections');
if (! is_array($connections) || count($connections) === 0) {
break;
}
foreach ($connections as $person) {
$resourceName = trim((string) Arr::get($person, 'resourceName', ''));
foreach ((array) Arr::get($person, 'emailAddresses', []) as $emailRow) {
$email = trim((string) Arr::get($emailRow, 'value', ''));
if ($email !== '' && $resourceName !== '') {
$existingEmails[strtolower($email)] = $resourceName;
}
}
foreach ((array) Arr::get($person, 'phoneNumbers', []) as $phoneRow) {
$phone = trim((string) Arr::get($phoneRow, 'value', ''));
if ($phone !== '' && $resourceName !== '') {
$normalized = $this->normalizePhone($phone);
if ($normalized !== '') {
$existingPhones[$normalized] = $resourceName;
}
}
}
}
$nextPageToken = $response->json('nextPageToken');
if (! is_string($nextPageToken) || $nextPageToken === '') {
break;
}
}
return [$existingEmails, $existingPhones, $token];
}
private function resolveAccessToken(Amministratore $amministratore, array $google, array $oauth, bool $dryRun, 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 === '') {
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 === []) {
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->warn("Admin {$amministratore->id}: refresh token fallito ({$lastError}).");
return null;
}
$newAccessToken = trim((string) Arr::get($payload, 'access_token', ''));
if ($newAccessToken === '') {
return null;
}
if (! $dryRun) {
$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 normalizePhone(string $phone): string
{
return preg_replace('/[^0-9+]/', '', trim($phone)) ?? '';
}
private function mobileForGoogle(RubricaUniversale $record): ?string
{
$candidate = trim((string) ($record->telefono_cellulare ?? ''));
if ($candidate === '') {
$national = trim((string) ($record->telefono_cellulare_nazionale ?? ''));
if ($national !== '') {
$prefix = trim((string) ($record->prefisso_cellulare ?? '+39'));
$candidate = $prefix . $national;
}
}
return PhoneNumber::toE164Italy($candidate);
}
/** @return array<int, array<string, string>> */
private function emailsForGoogle(RubricaUniversale $record): array
{
$emails = [];
$seen = [];
$push = function (?string $value, string $type, string $formattedType) use (&$emails, &$seen): void {
$email = strtolower(trim((string) $value));
if ($email === '' || isset($seen[$email])) {
return;
}
$seen[$email] = true;
$emails[] = [
'value' => $email,
'type' => $type,
'formattedType' => $formattedType,
];
};
foreach ($this->splitEmailValues((string) ($record->email ?? '')) as $email) {
$push($email, 'work', 'Email');
}
foreach ($this->splitEmailValues((string) ($record->pec ?? '')) as $email) {
$push($email, 'other', 'PEC');
}
if (Schema::hasTable('rubrica_contatti_canali')) {
$channels = RubricaContattoCanale::query()
->where('rubrica_id', (int) $record->id)
->whereIn('tipo', ['email', 'pec'])
->orderByDesc('is_principale')
->orderBy('id')
->get(['tipo', 'valore']);
foreach ($channels as $channel) {
$push((string) ($channel->valore ?? ''), (string) ($channel->tipo ?? '') === 'pec' ? 'other' : 'work', (string) ($channel->tipo ?? '') === 'pec' ? 'PEC' : 'Email');
}
}
return $emails;
}
/** @return array<int, array<string, string>> */
private function phonesForGoogle(RubricaUniversale $record): array
{
$phones = [];
$seen = [];
$push = function (?string $value, string $type, string $formattedType) use (&$phones, &$seen): void {
$phone = trim((string) $value);
if ($phone === '') {
return;
}
$normalized = $this->normalizePhone($phone);
if ($normalized === '' || isset($seen[$normalized])) {
return;
}
$seen[$normalized] = true;
$phones[] = [
'value' => $phone,
'type' => $type,
'formattedType' => $formattedType,
];
};
foreach ($this->splitPhoneValues((string) ($record->telefono_cellulare ?? '')) as $phone) {
$push(PhoneNumber::toE164Italy($phone) ?? $phone, 'mobile', 'Cellulare');
}
if (($primaryMobile = $this->mobileForGoogle($record)) !== null) {
$push($primaryMobile, 'mobile', 'Cellulare');
}
foreach ($this->splitPhoneValues((string) ($record->telefono_ufficio ?? '')) as $phone) {
$push($phone, 'work', 'Ufficio');
}
foreach ($this->splitPhoneValues((string) ($record->telefono_casa ?? '')) as $phone) {
$push($phone, 'home', 'Casa');
}
if (Schema::hasTable('rubrica_contatti_canali')) {
$channels = RubricaContattoCanale::query()
->where('rubrica_id', (int) $record->id)
->whereIn('tipo', ['telefono', 'cellulare'])
->orderByDesc('is_principale')
->orderBy('id')
->get(['tipo', 'valore']);
foreach ($channels as $channel) {
$isMobile = (string) ($channel->tipo ?? '') === 'cellulare';
$value = $isMobile ? (PhoneNumber::toE164Italy((string) ($channel->valore ?? '')) ?? (string) ($channel->valore ?? '')) : (string) ($channel->valore ?? '');
$push($value, $isMobile ? 'mobile' : 'work', $isMobile ? 'Cellulare' : 'Telefono');
}
}
return $phones;
}
/** @return array<int, string> */
private function splitEmailValues(string $value): array
{
$parts = preg_split('/[;,\n\r]+/', trim($value)) ?: [];
return array_values(array_filter(array_map(function (string $part): ?string {
$email = strtolower(trim($part));
return filter_var($email, FILTER_VALIDATE_EMAIL) ? $email : null;
}, $parts)));
}
/** @return array<int, string> */
private function splitPhoneValues(string $value): array
{
$parts = preg_split('/[;,\n\r\/|]+/', trim($value)) ?: [];
return array_values(array_filter(array_map(function (string $part): ?string {
$clean = preg_replace('/[^0-9+]/', '', trim($part));
return is_string($clean) && $clean !== '' ? $clean : null;
}, $parts)));
}
/**
* @return array{0:string,1:string,2:string}
*/
private function buildGoogleNameParts(RubricaUniversale $record): array
{
$nome = trim((string) ($record->nome ?? ''));
$cognome = trim((string) ($record->cognome ?? ''));
// Legacy imports can contain "Nome Cognome" inside nome with empty cognome.
if ($cognome === '' && str_contains($nome, ' ')) {
$parts = preg_split('/\s+/', $nome) ?: [];
if (count($parts) > 1) {
$nome = trim((string) array_shift($parts));
$cognome = trim(implode(' ', $parts));
}
}
$displayName = trim($nome . ' ' . $cognome);
if ($displayName === '') {
$displayName = trim((string) ($record->ragione_sociale ?? ''));
}
if ($displayName === '') {
$displayName = 'Contatto NetGescon';
}
return [$nome, $cognome, $displayName];
}
}