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'], 'direction' => ['nullable', 'in:in_arrivo,in_uscita,persa'], 'outcome' => ['nullable', 'string', 'max:255'], 'event_id' => ['nullable', 'string', 'max:100'], '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']); $rubrica = $this->findRubricaByPhone($normalized); $postIt = null; if (Schema::hasTable('chiamate_post_it')) { $postIt = ChiamataPostIt::query()->create([ 'stabile_id' => null, 'rubrica_id' => $rubrica?->id, 'ticket_id' => null, 'creato_da_user_id' => null, 'telefono' => $normalized !== '' ? $normalized : (string) $payload['phone'], 'nome_chiamante' => $rubrica?->nome_completo ?: ($payload['name'] ?? null), 'direzione' => $payload['direction'] ?? 'in_arrivo', 'esito' => $payload['outcome'] ?? null, 'origine' => 'panasonic_ns1000', 'origine_id' => $payload['event_id'] ?? null, 'oggetto' => 'Chiamata ' . (($payload['direction'] ?? 'in_arrivo') === 'in_arrivo' ? 'in arrivo' : 'telefonica'), 'nota' => $this->buildNote($payload), 'priorita' => 'Media', 'stato' => 'post_it', 'chiamata_il' => $payload['called_at'] ?? now(), ]); } $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 ? [ 'id' => $rubrica->id, 'nome_completo' => $rubrica->nome_completo, 'categoria' => $rubrica->categoria, ] : null, 'post_it_id' => $postIt?->id, 'called_extension' => $payload['called_extension'] ?? null, ]); } public function lookup(Request $request): JsonResponse { if (! $this->isAuthorized($request)) { $this->logUnauthorizedAttempt($request, 'lookup'); return response()->json(['ok' => false, 'message' => 'Unauthorized'], 401); } $data = $request->validate([ 'phone' => ['required', 'string', 'max:64'], ]); $normalized = PhoneNumber::normalizeForMatch((string) $data['phone']); $rubrica = $this->findRubricaByPhone($normalized); return response()->json([ 'ok' => true, 'phone_normalized' => $normalized, 'matched' => (bool) $rubrica, 'rubrica' => $rubrica ? [ 'id' => $rubrica->id, 'nome_completo' => $rubrica->nome_completo, 'telefono_ufficio' => $rubrica->telefono_ufficio, 'telefono_cellulare' => $rubrica->telefono_cellulare, 'telefono_casa' => $rubrica->telefono_casa, 'categoria' => $rubrica->categoria, ] : null, ]); } 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'], 'outcome' => ['nullable', 'string', 'max:255'], 'duration_seconds' => ['nullable', 'integer', 'min:0', 'max:86400'], 'ended_at' => ['nullable', 'date'], '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'] ?? '')); $postIt = $this->findOpenPostIt( (string) ($payload['event_id'] ?? ''), $normalized !== '' ? $normalized : (string) ($payload['phone'] ?? '') ); $created = false; if (! $postIt && Schema::hasTable('chiamate_post_it')) { $created = true; $rubrica = $this->findRubricaByPhone($normalized); $postIt = ChiamataPostIt::query()->create([ 'stabile_id' => null, 'rubrica_id' => $rubrica?->id, 'ticket_id' => null, 'creato_da_user_id' => null, 'telefono' => $normalized !== '' ? $normalized : (string) ($payload['phone'] ?? ''), 'nome_chiamante' => $rubrica?->nome_completo, 'direzione' => $payload['direction'] ?? 'in_arrivo', 'esito' => $payload['outcome'] ?? null, 'origine' => 'panasonic_ns1000', 'origine_id' => $payload['event_id'] ?? null, 'oggetto' => 'Chiamata terminata (evento senza incoming)', 'nota' => $this->buildNote($payload), 'priorita' => 'Media', 'stato' => 'post_it', 'chiamata_il' => $payload['ended_at'] ?? now(), ]); } 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); } $postIt->esito = $payload['outcome'] ?? $postIt->esito; $postIt->stato = 'chiusa'; if (Schema::hasColumn('chiamate_post_it', 'durata_secondi')) { $postIt->durata_secondi = $payload['duration_seconds'] ?? $postIt->durata_secondi; } if (Schema::hasColumn('chiamate_post_it', 'chiusa_il')) { $postIt->chiusa_il = $payload['ended_at'] ?? now(); } if (! empty($payload['note'])) { $existing = trim((string) ($postIt->nota ?? '')); $suffix = 'Note chiusura PBX: ' . $payload['note']; $postIt->nota = $existing !== '' ? ($existing . "\n" . $suffix) : $suffix; } $postIt->save(); $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, 'stato' => $postIt->stato, 'esito' => $postIt->esito, 'durata_secondi' => Schema::hasColumn('chiamate_post_it', 'durata_secondi') ? $postIt->durata_secondi : null, ]); } 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 === '') { $configured = (string) env('CTI_SHARED_SECRET', ''); } if ($configured === '') { $configured = (string) env('CTI_SHARED_TOKEN', ''); } if ($configured === '') { $configured = (string) env('CTI_TOKEN', ''); } if ($configured === '') { $configured = (string) env('CTI_PANASONIC_TOKEN', ''); } 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; } if ((string) $request->header('X-CTI-Mirrored', '0') === '1') { return; } $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 { $digits = PhoneNumber::normalizeDigits($phone); if ($digits === '') { return null; } $tail = substr($digits, -7); if ($tail === false || $tail === '') { return null; } return RubricaUniversale::query() ->where(function ($q) use ($tail) { $q->orWhere('telefono_cellulare', 'like', '%' . $tail . '%') ->orWhere('telefono_ufficio', 'like', '%' . $tail . '%') ->orWhere('telefono_casa', 'like', '%' . $tail . '%') ->orWhere('telefono_cellulare_nazionale', 'like', '%' . $tail . '%'); }) ->orderByDesc('updated_at') ->first(); } private function findOpenPostIt(string $eventId, string $phone): ?ChiamataPostIt { if (! Schema::hasTable('chiamate_post_it')) { return null; } if ($eventId !== '') { $byEvent = ChiamataPostIt::query() ->where('origine', 'panasonic_ns1000') ->where('origine_id', $eventId) ->where('stato', '!=', 'chiusa') ->orderByDesc('id') ->first(); if ($byEvent) { return $byEvent; } } $digits = PhoneNumber::normalizeDigits($phone); if ($digits === '') { return null; } $tail = substr($digits, -7); if ($tail === false || $tail === '') { return null; } return ChiamataPostIt::query() ->where('stato', '!=', 'chiusa') ->where('origine', 'panasonic_ns1000') ->where('telefono', 'like', '%' . $tail . '%') ->orderByDesc('chiamata_il') ->first(); } 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']; } if (! empty($payload['event_id'])) { $lines[] = 'Event ID: ' . $payload['event_id']; } if (! empty($payload['note'])) { $lines[] = 'Note PBX: ' . $payload['note']; } 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)); } }