870 lines
34 KiB
PHP
870 lines
34 KiB
PHP
<?php
|
|
namespace App\Http\Controllers\Api;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\ChiamataPostIt;
|
|
use App\Models\CommunicationMessage;
|
|
use App\Models\PbxClickToCallRequest;
|
|
use App\Models\RubricaUniversale;
|
|
use App\Services\Cti\PbxRoutingService;
|
|
use App\Support\PhoneNumber;
|
|
use Carbon\CarbonInterface;
|
|
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', 'string', 'max:32'],
|
|
'outcome' => ['nullable', 'string', 'max:255'],
|
|
'event_id' => ['nullable', 'string', 'max:100'],
|
|
'called_extension' => ['nullable', 'string', 'max:30'],
|
|
'calling_extension' => ['nullable', 'string', 'max:30'],
|
|
'event_type' => ['nullable', 'string', 'max:60'],
|
|
'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);
|
|
$direction = $this->normalizeDirection((string) ($payload['direction'] ?? ''));
|
|
$routing = app(PbxRoutingService::class)->resolveByExtension((string) ($payload['called_extension'] ?? ''));
|
|
|
|
$postIt = null;
|
|
$calledAt = $this->normalizePayloadDateTime($payload['called_at'] ?? null);
|
|
if (Schema::hasTable('chiamate_post_it')) {
|
|
$postIt = ChiamataPostIt::query()->create([
|
|
'stabile_id' => $routing['stabile_id'],
|
|
'rubrica_id' => $rubrica?->id,
|
|
'ticket_id' => null,
|
|
'creato_da_user_id' => $routing['user_id'],
|
|
'telefono' => $normalized !== '' ? $normalized : (string) $payload['phone'],
|
|
'nome_chiamante' => $rubrica?->nome_completo ?: ($payload['name'] ?? null),
|
|
'direzione' => $direction['post_it'],
|
|
'esito' => $payload['outcome'] ?? null,
|
|
'origine' => 'panasonic_ns1000',
|
|
'origine_id' => $payload['event_id'] ?? null,
|
|
'oggetto' => 'Chiamata ' . ($direction['post_it'] === 'in_uscita' ? 'telefonica' : 'in arrivo'),
|
|
'nota' => $this->buildNote($payload),
|
|
'priorita' => 'Media',
|
|
'stato' => 'post_it',
|
|
'chiamata_il' => $calledAt ?? now(),
|
|
]);
|
|
}
|
|
|
|
$message = $this->storeIncomingCommunicationMessage(
|
|
$payload,
|
|
$normalized,
|
|
$routing,
|
|
$rubrica,
|
|
$direction['communication'],
|
|
$postIt?->id
|
|
);
|
|
|
|
$this->mirrorToPeerIfEnabled($request, 'incoming', $payload);
|
|
|
|
$this->logTrace('incoming.stored', $request, $payload, [
|
|
'trace_id' => $traceId,
|
|
'post_it_id' => $postIt?->id,
|
|
'message_id' => $message?->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,
|
|
'communication_message_id' => $message?->id,
|
|
'assigned_user_id' => $routing['user_id'],
|
|
'amministratore_id' => $routing['amministratore_id'],
|
|
'studio_role' => $routing['studio_role'],
|
|
'studio_mode' => $routing['studio_mode'],
|
|
'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', 'string', 'max:32'],
|
|
'called_extension' => ['nullable', 'string', 'max:30'],
|
|
'calling_extension' => ['nullable', 'string', 'max:30'],
|
|
'event_type' => ['nullable', 'string', 'max:60'],
|
|
'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,
|
|
]);
|
|
|
|
try {
|
|
$normalized = PhoneNumber::normalizeForMatch((string) ($payload['phone'] ?? ''));
|
|
$direction = $this->normalizeDirection((string) ($payload['direction'] ?? ''));
|
|
$endedAt = $this->normalizePayloadDateTime($payload['ended_at'] ?? null);
|
|
$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' => $direction['post_it'],
|
|
'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' => $endedAt ?? 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 = $endedAt ?? 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();
|
|
|
|
$message = $this->closeCommunicationMessage($payload, $normalized, $direction['communication'], $postIt->id);
|
|
|
|
$this->mirrorToPeerIfEnabled($request, 'call-ended', $payload);
|
|
|
|
$this->logTrace('call-ended.closed', $request, $payload, [
|
|
'trace_id' => $traceId,
|
|
'post_it_id' => $postIt->id,
|
|
'message_id' => $message?->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,
|
|
'communication_message_id' => $message?->id,
|
|
'stato' => $postIt->stato,
|
|
'esito' => $postIt->esito,
|
|
'durata_secondi' => Schema::hasColumn('chiamate_post_it', 'durata_secondi') ? $postIt->durata_secondi : null,
|
|
]);
|
|
} catch (\Throwable $e) {
|
|
Log::error('CTI Panasonic call-ended failed', [
|
|
'trace_id' => $traceId,
|
|
'event_id' => (string) ($payload['event_id'] ?? ''),
|
|
'called_extension' => (string) ($payload['called_extension'] ?? ''),
|
|
'calling_extension' => (string) ($payload['calling_extension'] ?? ''),
|
|
'direction' => (string) ($payload['direction'] ?? ''),
|
|
'phone' => (string) ($payload['phone'] ?? ''),
|
|
'error' => $e->getMessage(),
|
|
'exception' => get_class($e),
|
|
]);
|
|
|
|
return response()->json([
|
|
'ok' => false,
|
|
'message' => 'Call ended processing failed',
|
|
'trace_id' => $traceId,
|
|
'error' => $e->getMessage(),
|
|
], 500);
|
|
}
|
|
}
|
|
|
|
public function pendingClickToCall(Request $request): JsonResponse
|
|
{
|
|
if (! $this->isAuthorized($request)) {
|
|
$this->logUnauthorizedAttempt($request, 'click-to-call/pending');
|
|
return response()->json(['ok' => false, 'message' => 'Unauthorized'], 401);
|
|
}
|
|
|
|
$data = $request->validate([
|
|
'extension' => ['nullable', 'string', 'max:30'],
|
|
'limit' => ['nullable', 'integer', 'min:1', 'max:100'],
|
|
]);
|
|
|
|
$extension = app(PbxRoutingService::class)->normalizeExtension((string) ($data['extension'] ?? ''));
|
|
$limit = (int) ($data['limit'] ?? 20);
|
|
|
|
$requests = PbxClickToCallRequest::query()
|
|
->where('status', 'pending')
|
|
->when($extension !== '', function ($query) use ($extension): void {
|
|
$query->where('source_extension', $extension);
|
|
})
|
|
->orderByRaw('COALESCE(requested_at, created_at) asc')
|
|
->limit($limit)
|
|
->get();
|
|
|
|
return response()->json([
|
|
'ok' => true,
|
|
'count' => $requests->count(),
|
|
'requests' => $requests->map(fn(PbxClickToCallRequest $clickToCallRequest): array=> $this->serializeClickToCallRequest($clickToCallRequest))->values(),
|
|
]);
|
|
}
|
|
|
|
public function claimClickToCall(Request $request, PbxClickToCallRequest $clickToCallRequest): JsonResponse
|
|
{
|
|
if (! $this->isAuthorized($request)) {
|
|
$this->logUnauthorizedAttempt($request, 'click-to-call/claim');
|
|
return response()->json(['ok' => false, 'message' => 'Unauthorized'], 401);
|
|
}
|
|
|
|
$data = $request->validate([
|
|
'adapter' => ['nullable', 'string', 'max:120'],
|
|
'note' => ['nullable', 'string'],
|
|
]);
|
|
|
|
$clickToCallRequest->refresh();
|
|
if ($clickToCallRequest->status !== 'pending') {
|
|
return response()->json([
|
|
'ok' => false,
|
|
'message' => 'Request not pending anymore',
|
|
'status' => $clickToCallRequest->status,
|
|
], 409);
|
|
}
|
|
|
|
$clickToCallRequest->status = 'processing';
|
|
$clickToCallRequest->metadata = $this->mergeMetadata($clickToCallRequest->metadata, [
|
|
'adapter' => (string) ($data['adapter'] ?? ''),
|
|
'claimed_at' => now()->toIso8601String(),
|
|
'claim_note' => $data['note'] ?? null,
|
|
]);
|
|
$clickToCallRequest->save();
|
|
|
|
return response()->json([
|
|
'ok' => true,
|
|
'request' => $this->serializeClickToCallRequest($clickToCallRequest),
|
|
]);
|
|
}
|
|
|
|
public function updateClickToCallStatus(Request $request, PbxClickToCallRequest $clickToCallRequest): JsonResponse
|
|
{
|
|
if (! $this->isAuthorized($request)) {
|
|
$this->logUnauthorizedAttempt($request, 'click-to-call/status');
|
|
return response()->json(['ok' => false, 'message' => 'Unauthorized'], 401);
|
|
}
|
|
|
|
$data = $request->validate([
|
|
'status' => ['required', 'in:processing,accepted,completed,failed,cancelled'],
|
|
'note' => ['nullable', 'string'],
|
|
'event_id' => ['nullable', 'string', 'max:100'],
|
|
'provider_call_id' => ['nullable', 'string', 'max:120'],
|
|
'called_extension' => ['nullable', 'string', 'max:30'],
|
|
'duration_seconds' => ['nullable', 'integer', 'min:0', 'max:86400'],
|
|
'outcome' => ['nullable', 'string', 'max:255'],
|
|
'error_code' => ['nullable', 'string', 'max:80'],
|
|
'error_message' => ['nullable', 'string', 'max:255'],
|
|
]);
|
|
|
|
$clickToCallRequest->status = (string) $data['status'];
|
|
if (in_array($clickToCallRequest->status, ['completed', 'failed', 'cancelled'], true)) {
|
|
$clickToCallRequest->processed_at = now();
|
|
}
|
|
|
|
if (! empty($data['note'])) {
|
|
$clickToCallRequest->note = trim(implode("\n", array_filter([
|
|
(string) ($clickToCallRequest->note ?? ''),
|
|
(string) $data['note'],
|
|
])));
|
|
}
|
|
|
|
$clickToCallRequest->metadata = $this->mergeMetadata($clickToCallRequest->metadata, [
|
|
'event_id' => $data['event_id'] ?? null,
|
|
'provider_call_id' => $data['provider_call_id'] ?? null,
|
|
'called_extension' => app(PbxRoutingService::class)->normalizeExtension((string) ($data['called_extension'] ?? '')),
|
|
'duration_seconds' => $data['duration_seconds'] ?? null,
|
|
'outcome' => $data['outcome'] ?? null,
|
|
'error_code' => $data['error_code'] ?? null,
|
|
'error_message' => $data['error_message'] ?? null,
|
|
'status_updated_at' => now()->toIso8601String(),
|
|
]);
|
|
$clickToCallRequest->save();
|
|
|
|
return response()->json([
|
|
'ok' => true,
|
|
'request' => $this->serializeClickToCallRequest($clickToCallRequest),
|
|
]);
|
|
}
|
|
|
|
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 = trim((string) config('cti.shared_token', ''));
|
|
if ($configured !== '') {
|
|
return $configured;
|
|
}
|
|
|
|
foreach ((array) config('cti.legacy_tokens', []) as $legacyToken) {
|
|
$candidate = trim((string) $legacyToken);
|
|
if ($candidate !== '') {
|
|
return $candidate;
|
|
}
|
|
}
|
|
|
|
$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 = (bool) config('cti.mirror_enabled', false);
|
|
if (! $enabled) {
|
|
return;
|
|
}
|
|
|
|
if ((string) $request->header('X-CTI-Mirrored', '0') === '1') {
|
|
return;
|
|
}
|
|
|
|
$baseUrl = rtrim((string) config('cti.mirror_base_url', ''), '/');
|
|
if ($baseUrl === '') {
|
|
return;
|
|
}
|
|
|
|
$token = $this->resolveConfiguredToken();
|
|
if ($token === '') {
|
|
return;
|
|
}
|
|
|
|
$timeout = (int) config('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);
|
|
$direction = $this->normalizeDirection((string) ($payload['direction'] ?? ''));
|
|
|
|
$lines = [
|
|
'Origine: Panasonic NS1000 (CSTA)',
|
|
'Classificazione: ' . $classification,
|
|
'Direzione normalizzata: ' . $direction['communication'],
|
|
];
|
|
|
|
if ($isTest) {
|
|
$lines[] = 'Flag test: SI';
|
|
}
|
|
|
|
if (! empty($payload['called_extension'])) {
|
|
$lines[] = 'Interno chiamato: ' . $payload['called_extension'];
|
|
}
|
|
|
|
if (! empty($payload['calling_extension'])) {
|
|
$lines[] = 'Interno chiamante: ' . $payload['calling_extension'];
|
|
}
|
|
|
|
if (! empty($payload['event_type'])) {
|
|
$lines[] = 'Evento CSTA: ' . $payload['event_type'];
|
|
}
|
|
|
|
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_type' => (string) ($payload['event_type'] ?? ''),
|
|
'event_id' => (string) ($payload['event_id'] ?? ''),
|
|
'calling_extension' => (string) ($payload['calling_extension'] ?? ''),
|
|
'called_extension' => (string) ($payload['called_extension'] ?? ''),
|
|
'direction' => (string) ($payload['direction'] ?? ''),
|
|
'phone_tail' => $phoneDigits !== '' ? substr($phoneDigits, -4) : '',
|
|
], $extra));
|
|
}
|
|
|
|
private function storeIncomingCommunicationMessage(
|
|
array $payload,
|
|
string $normalizedPhone,
|
|
array $routing,
|
|
?RubricaUniversale $rubrica,
|
|
string $direction,
|
|
?int $postItId
|
|
): ?CommunicationMessage {
|
|
if (! Schema::hasTable('communication_messages')) {
|
|
return null;
|
|
}
|
|
|
|
$message = new CommunicationMessage([
|
|
'channel' => 'panasonic_csta',
|
|
'direction' => $direction,
|
|
'external_chat_id' => (string) ($payload['called_extension'] ?? ''),
|
|
'external_message_id' => (string) ($payload['event_id'] ?? ''),
|
|
'phone_number' => $normalizedPhone !== '' ? $normalizedPhone : (string) $payload['phone'],
|
|
'sender_name' => $rubrica?->nome_completo ?: ($payload['name'] ?? null),
|
|
'message_text' => $this->buildNote($payload),
|
|
'attachments' => null,
|
|
'status' => 'received',
|
|
'received_at' => $this->normalizePayloadDateTime($payload['called_at'] ?? null) ?? now(),
|
|
'metadata' => [
|
|
'provider' => 'panasonic_ns1000',
|
|
'amministratore_id' => $routing['amministratore_id'],
|
|
'studio_role' => $routing['studio_role'],
|
|
'studio_mode' => $routing['studio_mode'],
|
|
'called_extension' => $routing['extension'],
|
|
'calling_extension' => app(PbxRoutingService::class)->normalizeExtension((string) ($payload['calling_extension'] ?? '')),
|
|
'event_id' => (string) ($payload['event_id'] ?? ''),
|
|
'event_type' => (string) ($payload['event_type'] ?? 'Delivered'),
|
|
'rubrica_id' => $rubrica?->id,
|
|
'post_it_id' => $postItId,
|
|
'raw_direction' => (string) ($payload['direction'] ?? ''),
|
|
'outcome' => $payload['outcome'] ?? null,
|
|
'note' => $payload['note'] ?? null,
|
|
],
|
|
]);
|
|
|
|
if (Schema::hasColumn('communication_messages', 'target_extension')) {
|
|
$message->target_extension = $routing['extension'] !== '' ? $routing['extension'] : null;
|
|
}
|
|
|
|
if (Schema::hasColumn('communication_messages', 'assigned_user_id')) {
|
|
$message->assigned_user_id = $routing['user_id'];
|
|
}
|
|
|
|
if (Schema::hasColumn('communication_messages', 'stabile_id')) {
|
|
$message->stabile_id = $routing['stabile_id'];
|
|
}
|
|
|
|
if (Schema::hasColumn('communication_messages', 'post_it_id')) {
|
|
$message->post_it_id = $postItId;
|
|
}
|
|
|
|
$message->save();
|
|
|
|
return $message;
|
|
}
|
|
|
|
private function closeCommunicationMessage(array $payload, string $normalizedPhone, string $direction, int $postItId): ?CommunicationMessage
|
|
{
|
|
if (! Schema::hasTable('communication_messages')) {
|
|
return null;
|
|
}
|
|
|
|
$message = $this->findCommunicationMessage((string) ($payload['event_id'] ?? ''), $normalizedPhone !== '' ? $normalizedPhone : (string) ($payload['phone'] ?? ''));
|
|
|
|
if (! $message) {
|
|
$routing = app(PbxRoutingService::class)->resolveByExtension((string) ($payload['called_extension'] ?? ''));
|
|
|
|
$message = $this->storeIncomingCommunicationMessage(
|
|
$payload,
|
|
$normalizedPhone,
|
|
$routing,
|
|
$this->findRubricaByPhone($normalizedPhone),
|
|
$direction,
|
|
$postItId
|
|
);
|
|
|
|
if ($message) {
|
|
$metadata = is_array($message->metadata) ? $message->metadata : [];
|
|
$metadata['ended_at'] = ($this->normalizePayloadDateTime($payload['ended_at'] ?? null) ?? now())->toDateTimeString();
|
|
$metadata['duration_seconds'] = $payload['duration_seconds'] ?? null;
|
|
$metadata['outcome'] = $payload['outcome'] ?? null;
|
|
$metadata['event_type'] = (string) ($payload['event_type'] ?? 'ConnectionCleared');
|
|
$message->status = 'completed';
|
|
$message->metadata = $metadata;
|
|
$message->save();
|
|
}
|
|
|
|
return $message;
|
|
}
|
|
|
|
$metadata = is_array($message->metadata) ? $message->metadata : [];
|
|
$metadata['ended_at'] = ($this->normalizePayloadDateTime($payload['ended_at'] ?? null) ?? now())->toDateTimeString();
|
|
$metadata['duration_seconds'] = $payload['duration_seconds'] ?? null;
|
|
$metadata['outcome'] = $payload['outcome'] ?? null;
|
|
$metadata['event_type'] = (string) ($payload['event_type'] ?? 'ConnectionCleared');
|
|
$metadata['note'] = $payload['note'] ?? ($metadata['note'] ?? null);
|
|
$metadata['post_it_id'] = $postItId;
|
|
|
|
$message->status = 'completed';
|
|
$message->message_text = $this->buildNote($payload);
|
|
$message->metadata = $metadata;
|
|
|
|
if (Schema::hasColumn('communication_messages', 'post_it_id')) {
|
|
$message->post_it_id = $postItId;
|
|
}
|
|
|
|
$message->save();
|
|
|
|
return $message;
|
|
}
|
|
|
|
private function findCommunicationMessage(string $eventId, string $phone): ?CommunicationMessage
|
|
{
|
|
if ($eventId !== '') {
|
|
$byEvent = CommunicationMessage::query()
|
|
->where('channel', 'panasonic_csta')
|
|
->where('external_message_id', $eventId)
|
|
->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 CommunicationMessage::query()
|
|
->where('channel', 'panasonic_csta')
|
|
->where('phone_number', 'like', '%' . $tail . '%')
|
|
->orderByDesc('id')
|
|
->first();
|
|
}
|
|
|
|
/**
|
|
* @return array{post_it:string,communication:string}
|
|
*/
|
|
private function normalizeDirection(string $direction): array
|
|
{
|
|
$value = mb_strtolower(trim($direction));
|
|
|
|
return match ($value) {
|
|
'in_uscita', 'outbound', 'outgoing' => ['post_it' => 'in_uscita', 'communication' => 'outbound'],
|
|
'persa', 'missed', 'no_answer' => ['post_it' => 'persa', 'communication' => 'inbound'],
|
|
default => ['post_it' => 'in_arrivo', 'communication' => 'inbound'],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param array<string,mixed>|null $existing
|
|
* @param array<string,mixed> $newData
|
|
* @return array<string,mixed>
|
|
*/
|
|
private function mergeMetadata(?array $existing, array $newData): array
|
|
{
|
|
$base = is_array($existing) ? $existing : [];
|
|
|
|
foreach ($newData as $key => $value) {
|
|
if ($value === null || $value === '') {
|
|
continue;
|
|
}
|
|
|
|
$base[$key] = $value;
|
|
}
|
|
|
|
return $base;
|
|
}
|
|
|
|
private function normalizePayloadDateTime(mixed $value): ?CarbonInterface
|
|
{
|
|
if ($value instanceof CarbonInterface) {
|
|
return $value;
|
|
}
|
|
|
|
if ($value instanceof \DateTimeInterface) {
|
|
return now()->setTimestamp($value->getTimestamp());
|
|
}
|
|
|
|
$raw = trim((string) ($value ?? ''));
|
|
if ($raw === '') {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
return now()->parse($raw);
|
|
} catch (\Throwable) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return array<string,mixed>
|
|
*/
|
|
private function serializeClickToCallRequest(PbxClickToCallRequest $clickToCallRequest): array
|
|
{
|
|
return [
|
|
'id' => $clickToCallRequest->id,
|
|
'requested_by_user_id' => $clickToCallRequest->requested_by_user_id,
|
|
'assigned_user_id' => $clickToCallRequest->assigned_user_id,
|
|
'stabile_id' => $clickToCallRequest->stabile_id,
|
|
'communication_message_id' => $clickToCallRequest->communication_message_id,
|
|
'source_extension' => $clickToCallRequest->source_extension,
|
|
'target_number' => $clickToCallRequest->target_number,
|
|
'status' => $clickToCallRequest->status,
|
|
'note' => $clickToCallRequest->note,
|
|
'requested_at' => optional($clickToCallRequest->requested_at)->toIso8601String(),
|
|
'processed_at' => optional($clickToCallRequest->processed_at)->toIso8601String(),
|
|
'metadata' => $clickToCallRequest->metadata,
|
|
'created_at' => optional($clickToCallRequest->created_at)->toIso8601String(),
|
|
'updated_at' => optional($clickToCallRequest->updated_at)->toIso8601String(),
|
|
];
|
|
}
|
|
}
|