diff --git a/app/Console/Commands/CtiSyncPostItFromStagingCommand.php b/app/Console/Commands/CtiSyncPostItFromStagingCommand.php new file mode 100644 index 0000000..04fe4aa --- /dev/null +++ b/app/Console/Commands/CtiSyncPostItFromStagingCommand.php @@ -0,0 +1,127 @@ +option('from-connection'); + $toConnection = (string) $this->option('to-connection'); + $sinceId = max(0, (int) $this->option('since-id')); + $limit = max(1, min(20000, (int) $this->option('limit'))); + $replace = (bool) $this->option('replace'); + $apply = (bool) $this->option('apply'); + + if (! Config::has('database.connections.' . $fromConnection)) { + $this->error("Connessione sorgente non definita: {$fromConnection}"); + return self::FAILURE; + } + + if (! Config::has('database.connections.' . $toConnection)) { + $this->error("Connessione destinazione non definita: {$toConnection}"); + return self::FAILURE; + } + + if ($fromConnection === $toConnection) { + $this->error('Connessione sorgente e destinazione coincidono. Interrompo per sicurezza.'); + return self::FAILURE; + } + + $fromCfg = (array) config('database.connections.' . $fromConnection, []); + $toCfg = (array) config('database.connections.' . $toConnection, []); + $samePhysicalDb = + strtolower((string) ($fromCfg['host'] ?? '')) === strtolower((string) ($toCfg['host'] ?? '')) && + (string) ($fromCfg['port'] ?? '') === (string) ($toCfg['port'] ?? '') && + strtolower((string) ($fromCfg['database'] ?? '')) === strtolower((string) ($toCfg['database'] ?? '')) && + strtolower((string) ($fromCfg['username'] ?? '')) === strtolower((string) ($toCfg['username'] ?? '')) && + (string) ($fromCfg['unix_socket'] ?? '') === (string) ($toCfg['unix_socket'] ?? ''); + + if ($samePhysicalDb) { + $this->error('Sorgente e destinazione puntano allo stesso DB fisico. Interrompo per sicurezza.'); + return self::FAILURE; + } + + if (! Schema::connection($fromConnection)->hasTable('chiamate_post_it')) { + $this->error('Tabella chiamate_post_it assente sulla connessione sorgente.'); + return self::FAILURE; + } + + if (! Schema::connection($toConnection)->hasTable('chiamate_post_it')) { + $this->error('Tabella chiamate_post_it assente sulla connessione destinazione.'); + return self::FAILURE; + } + + $sourceColumns = Schema::connection($fromConnection)->getColumnListing('chiamate_post_it'); + $targetColumns = Schema::connection($toConnection)->getColumnListing('chiamate_post_it'); + $columns = array_values(array_intersect($sourceColumns, $targetColumns)); + + if (! in_array('id', $columns, true)) { + $this->error('La tabella destinazione deve avere la colonna id per l\'upsert.'); + return self::FAILURE; + } + + $rows = DB::connection($fromConnection) + ->table('chiamate_post_it') + ->where('origine', 'panasonic_ns1000') + ->where('id', '>', $sinceId) + ->orderBy('id') + ->limit($limit) + ->get($columns); + + if ($rows->isEmpty()) { + $this->info('Nessun record Panasonic da sincronizzare.'); + return self::SUCCESS; + } + + $firstId = (int) ($rows->first()->id ?? 0); + $lastId = (int) ($rows->last()->id ?? 0); + $this->line("Record letti: {$rows->count()} (id {$firstId} -> {$lastId})"); + $this->line("Direzione: {$fromConnection} -> {$toConnection}"); + + if (! $apply) { + $this->warn('Dry-run attivo. Usa --apply per eseguire la sincronizzazione.'); + return self::SUCCESS; + } + + $payload = $rows + ->map(fn ($row): array => Arr::only((array) $row, $columns)) + ->all(); + + $updateColumns = array_values(array_filter($columns, fn (string $col): bool => $col !== 'id')); + + DB::connection($toConnection)->transaction(function () use ($toConnection, $replace, $payload, $updateColumns): void { + $query = DB::connection($toConnection)->table('chiamate_post_it'); + + if ($replace) { + $deleted = $query->where('origine', 'panasonic_ns1000')->delete(); + $this->line("Record Panasonic rimossi in destinazione: {$deleted}"); + } + + foreach (array_chunk($payload, 500) as $chunk) { + DB::connection($toConnection) + ->table('chiamate_post_it') + ->upsert($chunk, ['id'], $updateColumns); + } + }); + + $this->info('Sincronizzazione completata con successo.'); + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/Api/PanasonicCstaController.php b/app/Http/Controllers/Api/PanasonicCstaController.php index 03045c8..a3535b8 100644 --- a/app/Http/Controllers/Api/PanasonicCstaController.php +++ b/app/Http/Controllers/Api/PanasonicCstaController.php @@ -8,6 +8,8 @@ use App\Support\PhoneNumber; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Schema; class PanasonicCstaController extends Controller @@ -53,6 +55,8 @@ public function incoming(Request $request): JsonResponse ]); } + $this->mirrorToPeerIfEnabled($request, 'incoming', $payload); + return response()->json([ 'ok' => true, 'source' => 'panasonic_ns1000', @@ -169,6 +173,8 @@ public function callEnded(Request $request): JsonResponse $postIt->save(); + $this->mirrorToPeerIfEnabled($request, 'call-ended', $payload); + return response()->json([ 'ok' => true, 'source' => 'panasonic_ns1000', @@ -182,6 +188,19 @@ public function callEnded(Request $request): JsonResponse } private function isAuthorized(Request $request): bool + { + $configured = $this->resolveConfiguredToken(); + + if ($configured === '') { + return false; + } + + $token = (string) ($request->header('X-CTI-Token') ?: $request->input('token', '')); + + return hash_equals($configured, $token); + } + + private function resolveConfiguredToken(): string { $configured = (string) env('NETGESCON_CTI_SHARED_TOKEN', ''); if ($configured === '') { @@ -200,13 +219,47 @@ private function isAuthorized(Request $request): bool $configured = (string) env('CTI_PANASONIC_TOKEN', ''); } - if ($configured === '') { - return false; + return $configured; + } + + private function mirrorToPeerIfEnabled(Request $request, string $endpoint, array $payload): void + { + $enabled = filter_var((string) env('NETGESCON_CTI_MIRROR_ENABLED', 'false'), FILTER_VALIDATE_BOOL); + if (! $enabled) { + return; } - $token = (string) ($request->header('X-CTI-Token') ?: $request->input('token', '')); + if ((string) $request->header('X-CTI-Mirrored', '0') === '1') { + return; + } - return hash_equals($configured, $token); + $baseUrl = rtrim((string) env('NETGESCON_CTI_MIRROR_BASE_URL', ''), '/'); + if ($baseUrl === '') { + return; + } + + $token = $this->resolveConfiguredToken(); + if ($token === '') { + return; + } + + $timeout = (int) env('NETGESCON_CTI_MIRROR_TIMEOUT_SECONDS', 2); + $url = $baseUrl . '/api/v1/cti/panasonic/' . ltrim($endpoint, '/'); + + try { + Http::timeout(max(1, $timeout)) + ->withHeaders([ + 'X-CTI-Token' => $token, + 'X-CTI-Mirrored' => '1', + ]) + ->post($url, $payload); + } catch (\Throwable $e) { + Log::warning('CTI mirror failed', [ + 'endpoint' => $endpoint, + 'url' => $url, + 'error' => $e->getMessage(), + ]); + } } private function findRubricaByPhone(string $phone): ?RubricaUniversale diff --git a/bootstrap/app.php b/bootstrap/app.php index a2fe8fd..cad050d 100755 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,6 +1,7 @@ withMiddleware(function (Middleware $middleware) { $middleware->append(\App\Http\Middleware\ForceFilamentUiMiddleware::class); diff --git a/docs/NETGESCON-MODIFICHE.md b/docs/NETGESCON-MODIFICHE.md index cb0c65e..9308ccd 100644 --- a/docs/NETGESCON-MODIFICHE.md +++ b/docs/NETGESCON-MODIFICHE.md @@ -20,3 +20,4 @@ # Modifiche NetGescon | 11/03/2026 | 0.8.x-dev | CTI Panasonic / Post-It | Nuovi endpoint incoming, lookup e call-ended con chiusura automatica Post-it, salvataggio esito e durata chiamata | [U] | | 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] |