netgescon-day0/app/Http/Controllers/Api/PanasonicCstaController.php

454 lines
16 KiB
PHP

<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\ChiamataPostIt;
use App\Models\RubricaUniversale;
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;
use Illuminate\Support\Str;
class PanasonicCstaController extends Controller
{
public function incoming(Request $request): JsonResponse
{
if (! $this->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));
}
}