Ver código fonte

feat: Unipile implementation complete (.env.example with new variables)

EduLascala 1 semana atrás
pai
commit
563fb6dcb8

+ 110 - 0
controllers/InteractionSendMessageController.php

@@ -0,0 +1,110 @@
+<?php
+
+namespace Controllers;
+
+use Libs\Logger;
+use Libs\Payload;
+use Libs\Roles;
+use Libs\Validator;
+use Models\IntegrationsModel;
+use Models\UnipileMessagesModel;
+use Models\UserModel;
+use Psr\Http\Message\ServerRequestInterface;
+use Services\UnipileClient;
+
+class InteractionSendMessageController
+{
+    private UserModel $userModel;
+    private IntegrationsModel $integrationsModel;
+    private UnipileMessagesModel $messagesModel;
+    private UnipileClient $unipileClient;
+
+    public function __construct()
+    {
+        $this->userModel = new UserModel();
+        $this->integrationsModel = new IntegrationsModel();
+        $this->messagesModel = new UnipileMessagesModel();
+        $this->unipileClient = new UnipileClient();
+    }
+
+    public function __invoke(ServerRequestInterface $request)
+    {
+        $userId = (int) ($request->getAttribute('user_id') ?? 0);
+        $userEmail = (string) ($request->getAttribute('user_email') ?? '');
+        $userRole = mb_strtolower(trim((string) ($request->getAttribute('user_role') ?? '')));
+        $body = json_decode((string) $request->getBody(), true) ?: [];
+        $conversationId = (int) ($body['conversation_id'] ?? 0);
+        $text = trim((string) ($body['text'] ?? ''));
+
+        if ($userId <= 0) {
+            return Payload::fail('Unauthorized: Missing authenticated user', [], 'E_VALIDATE', 401);
+        }
+
+        $validator = (new Validator(['conversation_id' => $conversationId, 'text' => $text]))
+            ->required('conversation_id')->intRange('conversation_id', 1)
+            ->required('text')->maxLength('text', 4000);
+
+        if ($validator->fails()) {
+            return Payload::fail($validator->firstError(), [], 'E_VALIDATE', 400);
+        }
+
+        if (!$this->unipileClient->isConfigured()) {
+            return Payload::fail('Unipile is not configured', [], 'E_GENERIC', 500);
+        }
+
+        try {
+            $companyId = $this->userModel->getCompanyIdByUserId($userId);
+            if ($companyId === null) {
+                return Payload::fail('User not found', [], 'E_NOT_FOUND', 404);
+            }
+
+            $conversation = $this->messagesModel->getConversationForSending($companyId, $conversationId);
+            if ($conversation === null) {
+                return Payload::fail('Conversation not found', [], 'E_NOT_FOUND', 404);
+            }
+
+            if (!$this->canSend($companyId, $userEmail, $userRole, $conversation)) {
+                return Payload::fail('Forbidden: insufficient permissions', [], 'E_FORBIDDEN', 403);
+            }
+
+            if (!(bool) ($conversation['integration_is_connected'] ?? false)) {
+                return Payload::fail('Integration is not connected', [], 'E_VALIDATE', 400);
+            }
+
+            $chatId = (string) ($conversation['conversation_external_id'] ?? '');
+            if ($chatId === '') {
+                return Payload::fail('Conversation is missing external chat id', [], 'E_VALIDATE', 400);
+            }
+
+            $response = $this->unipileClient->sendTextMessage($chatId, $text);
+            $message = $this->messagesModel->createOutboundLocalMessage($conversationId, $response, $text);
+            $this->messagesModel->updateConversationAfterOutbound($conversationId, $text);
+
+            return Payload::ok([
+                'message_id' => (int) ($message['message_id'] ?? 0),
+                'unipile' => $response,
+            ]);
+        } catch (\Throwable $e) {
+            Logger::error('Failed to send WhatsApp message through Unipile', ['error' => $e->getMessage()]);
+            return Payload::fail('Failed to send message', [], 'E_GENERIC', 500);
+        }
+    }
+
+    private function canSend(int $companyId, string $userEmail, string $userRole, array $conversation): bool
+    {
+        if (in_array($userRole, [Roles::ADMIN, Roles::MANAGER], true)) {
+            return true;
+        }
+
+        if ($userRole !== Roles::OPERATOR) {
+            return false;
+        }
+
+        $operatorId = $this->integrationsModel->getOperatorIdByUserEmail($companyId, $userEmail);
+        if ($operatorId <= 0) {
+            return false;
+        }
+
+        return (int) ($conversation['operator_id'] ?? 0) === $operatorId;
+    }
+}

+ 41 - 0
controllers/UnipileAccountsController.php

@@ -0,0 +1,41 @@
+<?php
+
+namespace Controllers;
+
+use Libs\Logger;
+use Libs\Payload;
+use Models\IntegrationsModel;
+use Models\UserModel;
+use Psr\Http\Message\ServerRequestInterface;
+
+class UnipileAccountsController
+{
+    private UserModel $userModel;
+    private IntegrationsModel $integrationsModel;
+
+    public function __construct()
+    {
+        $this->userModel = new UserModel();
+        $this->integrationsModel = new IntegrationsModel();
+    }
+
+    public function __invoke(ServerRequestInterface $request)
+    {
+        $userId = (int) ($request->getAttribute('user_id') ?? 0);
+        if ($userId <= 0) {
+            return Payload::fail('Unauthorized: Missing authenticated user', [], 'E_VALIDATE', 401);
+        }
+
+        try {
+            $companyId = $this->userModel->getCompanyIdByUserId($userId);
+            if ($companyId === null) {
+                return Payload::fail('User not found', [], 'E_NOT_FOUND', 404);
+            }
+
+            return Payload::ok(['items' => $this->integrationsModel->listByCompany($companyId)]);
+        } catch (\Throwable $e) {
+            Logger::error('Failed to list Unipile accounts', ['error' => $e->getMessage()]);
+            return Payload::fail('Failed to list integrations', [], 'E_GENERIC', 500);
+        }
+    }
+}

+ 104 - 0
controllers/UnipileHostedAuthWebhookController.php

@@ -0,0 +1,104 @@
+<?php
+
+namespace Controllers;
+
+use Libs\Logger;
+use Libs\Payload;
+use Models\IntegrationsModel;
+use Psr\Http\Message\ServerRequestInterface;
+
+class UnipileHostedAuthWebhookController
+{
+    private IntegrationsModel $integrationsModel;
+
+    public function __construct()
+    {
+        $this->integrationsModel = new IntegrationsModel();
+    }
+
+    public function __invoke(ServerRequestInterface $request)
+    {
+        if (!$this->isAuthorized($request)) {
+            return Payload::fail('Unauthorized', [], 'E_VALIDATE', 401);
+        }
+
+        $body = json_decode((string) $request->getBody(), true) ?: [];
+        $status = (string) ($body['status'] ?? '');
+        $accountId = (string) ($body['account_id'] ?? '');
+        $name = (string) ($body['name'] ?? '');
+
+        if ($status === '' || $accountId === '' || $name === '') {
+            return Payload::fail('Invalid callback payload', [], 'E_VALIDATE', 400);
+        }
+
+        $identity = $this->decodeName($name);
+        if ($identity === null) {
+            return Payload::fail('Invalid callback identity', [], 'E_VALIDATE', 400);
+        }
+
+        try {
+            $integration = $this->integrationsModel->upsertWhatsappIntegration(
+                (int) $identity['company_id'],
+                (int) $identity['user_id'],
+                (int) $identity['operator_id'],
+                $accountId,
+                $status,
+                $body
+            );
+
+            return Payload::ok(['integration' => $this->integrationsModel->formatIntegration($integration)]);
+        } catch (\Throwable $e) {
+            Logger::error('Failed to process Unipile hosted auth callback', ['error' => $e->getMessage()]);
+            return Payload::fail('Failed to process callback', [], 'E_GENERIC', 500);
+        }
+    }
+
+    private function isAuthorized(ServerRequestInterface $request): bool
+    {
+        $expected = (string) ($_ENV['UNIPILE_NOTIFY_SECRET'] ?? '');
+        if ($expected === '') {
+            return false;
+        }
+
+        $provided = (string) ($request->getQueryParams()['secret'] ?? '');
+        if ($provided === '') {
+            $provided = $request->getHeaderLine('Unipile-Notify-Secret');
+        }
+
+        return hash_equals($expected, $provided);
+    }
+
+    private function decodeName(string $name): ?array
+    {
+        $parts = explode('.', $name, 2);
+        if (count($parts) !== 2) {
+            return null;
+        }
+
+        [$encoded, $signature] = $parts;
+        $secret = (string) ($_ENV['JWT_SECRET'] ?? '');
+        $expected = hash_hmac('sha256', $encoded, $secret);
+        if (!hash_equals($expected, $signature)) {
+            return null;
+        }
+
+        $padded = str_pad(strtr($encoded, '-_', '+/'), strlen($encoded) % 4 === 0 ? strlen($encoded) : strlen($encoded) + 4 - strlen($encoded) % 4, '=', STR_PAD_RIGHT);
+        $json = base64_decode($padded, true);
+        if ($json === false) {
+            return null;
+        }
+
+        $payload = json_decode($json, true);
+        if (!is_array($payload)) {
+            return null;
+        }
+
+        foreach (['company_id', 'user_id', 'operator_id'] as $key) {
+            if (!isset($payload[$key]) || (int) $payload[$key] < 0) {
+                return null;
+            }
+        }
+
+        return $payload;
+    }
+}

+ 144 - 0
controllers/UnipileHostedLinkController.php

@@ -0,0 +1,144 @@
+<?php
+
+namespace Controllers;
+
+use Libs\Logger;
+use Libs\Payload;
+use Libs\Validator;
+use Models\IntegrationsModel;
+use Models\UserModel;
+use Psr\Http\Message\ServerRequestInterface;
+use Services\UnipileClient;
+
+class UnipileHostedLinkController
+{
+    private UserModel $userModel;
+    private IntegrationsModel $integrationsModel;
+    private UnipileClient $unipileClient;
+
+    public function __construct()
+    {
+        $this->userModel = new UserModel();
+        $this->integrationsModel = new IntegrationsModel();
+        $this->unipileClient = new UnipileClient();
+    }
+
+    public function __invoke(ServerRequestInterface $request)
+    {
+        $userId = (int) ($request->getAttribute('user_id') ?? 0);
+        $userEmail = (string) ($request->getAttribute('user_email') ?? '');
+        $body = json_decode((string) $request->getBody(), true) ?: [];
+        $type = mb_strtolower(trim((string) ($body['type'] ?? 'create')));
+        $integrationId = (int) ($body['integration_id'] ?? 0);
+
+        if ($userId <= 0) {
+            return Payload::fail('Unauthorized: Missing authenticated user', [], 'E_VALIDATE', 401);
+        }
+
+        $validator = (new Validator(['type' => $type]))->required('type')->in('type', ['create', 'reconnect']);
+        if ($validator->fails()) {
+            return Payload::fail($validator->firstError(), [], 'E_VALIDATE', 400);
+        }
+
+        if ($type === 'reconnect' && $integrationId <= 0) {
+            return Payload::fail('Missing or invalid integration_id', [], 'E_VALIDATE', 400);
+        }
+
+        if (!$this->unipileClient->isConfigured()) {
+            return Payload::fail('Unipile is not configured', [], 'E_GENERIC', 500);
+        }
+
+        if (trim((string) ($_ENV['UNIPILE_NOTIFY_SECRET'] ?? '')) === '') {
+            return Payload::fail('UNIPILE_NOTIFY_SECRET is not configured', [], 'E_GENERIC', 500);
+        }
+
+        try {
+            $companyId = $this->userModel->getCompanyIdByUserId($userId);
+            if ($companyId === null) {
+                return Payload::fail('User not found', [], 'E_NOT_FOUND', 404);
+            }
+
+            $operatorId = $this->integrationsModel->getOperatorIdByUserEmail($companyId, $userEmail);
+            $integration = null;
+            if ($type === 'reconnect') {
+                $integration = $this->integrationsModel->findById($companyId, $integrationId);
+                if ($integration === null) {
+                    return Payload::fail('Integration not found', [], 'E_NOT_FOUND', 404);
+                }
+            }
+
+            $hostedPayload = [
+                'type' => $type,
+                'providers' => ['WHATSAPP'],
+                'api_url' => $this->unipileClient->apiUrl(),
+                'expiresOn' => gmdate('c', time() + 1800),
+                'notify_url' => $this->publicUrl($request, '/v1/webhooks/unipile/hosted-auth') . '?secret=' . rawurlencode((string) ($_ENV['UNIPILE_NOTIFY_SECRET'] ?? '')),
+                'success_redirect_url' => $this->redirectUrl($request, 'UNIPILE_SUCCESS_REDIRECT_URL', '/v1/integrations/unipile/whatsapp/success'),
+                'failure_redirect_url' => $this->redirectUrl($request, 'UNIPILE_FAILURE_REDIRECT_URL', '/v1/integrations/unipile/whatsapp/failure'),
+                'name' => $this->signedName($companyId, $userId, $operatorId, $integrationId),
+            ];
+
+            if ($integration !== null) {
+                $hostedPayload['account_id'] = $integration['integration_account_id'];
+            }
+
+            $response = $this->unipileClient->createHostedAuthLink($hostedPayload);
+            $url = (string) ($response['url'] ?? '');
+            if ($url === '') {
+                Logger::warning('Unipile hosted auth response missing url', ['response' => $response]);
+                return Payload::fail('Failed to create hosted auth link', [], 'E_GENERIC', 502);
+            }
+
+            return Payload::ok(['url' => $url]);
+        } catch (\Throwable $e) {
+            Logger::error('Failed to create Unipile hosted auth link', ['error' => $e->getMessage()]);
+            return Payload::fail('Failed to create hosted auth link', [], 'E_GENERIC', 500);
+        }
+    }
+
+    private function signedName(int $companyId, int $userId, int $operatorId, int $integrationId): string
+    {
+        $secret = (string) ($_ENV['JWT_SECRET'] ?? '');
+        $payload = [
+            'company_id' => $companyId,
+            'user_id' => $userId,
+            'operator_id' => $operatorId,
+            'integration_id' => $integrationId,
+            'nonce' => bin2hex(random_bytes(12)),
+            'iat' => time(),
+        ];
+        $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+        if ($json === false) {
+            $json = '{}';
+        }
+        $encoded = rtrim(strtr(base64_encode($json), '+/', '-_'), '=');
+        $signature = hash_hmac('sha256', $encoded, $secret);
+
+        return $encoded . '.' . $signature;
+    }
+
+    private function redirectUrl(ServerRequestInterface $request, string $envKey, string $fallbackPath): string
+    {
+        $configured = trim((string) ($_ENV[$envKey] ?? ''));
+        if ($configured !== '') {
+            return $configured;
+        }
+
+        return $this->publicUrl($request, $fallbackPath);
+    }
+
+    private function publicUrl(ServerRequestInterface $request, string $path): string
+    {
+        $configured = rtrim(trim((string) ($_ENV['APP_PUBLIC_URL'] ?? '')), '/');
+        if ($configured !== '') {
+            return $configured . $path;
+        }
+
+        $host = $request->getHeaderLine('Host');
+        if ($host === '') {
+            $host = 'localhost:8080';
+        }
+
+        return 'https://' . $host . $path;
+    }
+}

+ 25 - 0
controllers/UnipileRedirectController.php

@@ -0,0 +1,25 @@
+<?php
+
+namespace Controllers;
+
+use Libs\Payload;
+use Psr\Http\Message\ServerRequestInterface;
+
+class UnipileRedirectController
+{
+    private string $status;
+
+    public function __construct(string $status)
+    {
+        $this->status = $status;
+    }
+
+    public function __invoke(ServerRequestInterface $request)
+    {
+        if ($this->status === 'success') {
+            return Payload::ok(['status' => 'success'], 'S_OK', 'WhatsApp account connected.');
+        }
+
+        return Payload::fail('WhatsApp account connection failed', ['status' => 'failure'], 'E_VALIDATE', 400);
+    }
+}

+ 104 - 0
controllers/UnipileWebhookController.php

@@ -0,0 +1,104 @@
+<?php
+
+namespace Controllers;
+
+use Libs\Logger;
+use Libs\Payload;
+use Models\IntegrationsModel;
+use Models\UnipileMessagesModel;
+use Psr\Http\Message\ServerRequestInterface;
+
+class UnipileWebhookController
+{
+    private IntegrationsModel $integrationsModel;
+    private UnipileMessagesModel $messagesModel;
+
+    public function __construct()
+    {
+        $this->integrationsModel = new IntegrationsModel();
+        $this->messagesModel = new UnipileMessagesModel();
+    }
+
+    public function __invoke(ServerRequestInterface $request)
+    {
+        if (!$this->isAuthorized($request)) {
+            return Payload::fail('Unauthorized', [], 'E_VALIDATE', 401);
+        }
+
+        $payload = json_decode((string) $request->getBody(), true) ?: [];
+        if ($payload === []) {
+            return Payload::fail('Invalid webhook payload', [], 'E_VALIDATE', 400);
+        }
+
+        try {
+            if (isset($payload['AccountStatus']) && is_array($payload['AccountStatus'])) {
+                return $this->handleAccountStatus($payload);
+            }
+
+            return $this->handleMessageEvent($payload);
+        } catch (\Throwable $e) {
+            Logger::error('Failed to process Unipile webhook', ['error' => $e->getMessage()]);
+            return Payload::fail('Failed to process webhook', [], 'E_GENERIC', 500);
+        }
+    }
+
+    private function handleAccountStatus(array $payload)
+    {
+        $statusPayload = $payload['AccountStatus'];
+        $accountId = (string) ($statusPayload['account_id'] ?? '');
+        $status = (string) ($statusPayload['message'] ?? '');
+        if ($accountId === '' || $status === '') {
+            return Payload::fail('Invalid account status payload', [], 'E_VALIDATE', 400);
+        }
+
+        $integration = $this->integrationsModel->updateStatusByAccountId($accountId, $status, $payload);
+        if ($integration !== null) {
+            $this->messagesModel->storeWebhookEvent(
+                (int) $integration['integration_id'],
+                'account_status',
+                $accountId . ':' . $status,
+                $payload,
+                true
+            );
+        }
+
+        return Payload::ok();
+    }
+
+    private function handleMessageEvent(array $payload)
+    {
+        $accountId = (string) ($payload['account_id'] ?? '');
+        $accountType = mb_strtoupper(trim((string) ($payload['account_type'] ?? '')));
+        $event = (string) ($payload['event'] ?? 'message_received');
+        if ($accountId === '' || $accountType !== 'WHATSAPP') {
+            return Payload::ok();
+        }
+
+        $integration = $this->integrationsModel->findByAccountId($accountId);
+        if ($integration === null) {
+            Logger::warning('Unipile webhook account not found', ['account_id' => $accountId, 'event' => $event]);
+            return Payload::ok();
+        }
+
+        $externalId = (string) ($payload['message_id'] ?? $payload['chat_id'] ?? hash('sha256', json_encode($payload)));
+        $processed = false;
+
+        if (in_array($event, ['message_received', 'message_edited', 'message_deleted', 'message_delivered', 'message_read'], true)) {
+            $processed = $this->messagesModel->upsertMessageFromWebhook($integration, $payload) !== null;
+        }
+
+        $this->messagesModel->storeWebhookEvent((int) $integration['integration_id'], $event, $externalId, $payload, $processed);
+
+        return Payload::ok();
+    }
+
+    private function isAuthorized(ServerRequestInterface $request): bool
+    {
+        $expected = (string) ($_ENV['UNIPILE_WEBHOOK_SECRET'] ?? '');
+        if ($expected === '') {
+            return false;
+        }
+
+        return hash_equals($expected, $request->getHeaderLine('Unipile-Auth'));
+    }
+}

+ 1 - 1
middlewares/CorsMiddleware.php

@@ -45,7 +45,7 @@ final class CorsMiddleware
         return $response
             ->withHeader('Access-Control-Allow-Origin', '*')
             ->withHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS')
-            ->withHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
+            ->withHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, Unipile-Auth, Unipile-Notify-Secret')
             ->withHeader('Access-Control-Max-Age', '86400');
     }
 }

+ 86 - 24
migrations/migrations_v1.sql

@@ -8,23 +8,67 @@ CREATE TABLE company (
     company_cnpj                       VARCHAR(14)  NOT NULL UNIQUE,
     company_logo                       TEXT NOT NULL,
     company_created_at                 TIMESTAMP NOT NULL DEFAULT NOW(),
-    company_deleted_at                 TIMESTAMP NOT NULL DEFAULT 'delete'
+    company_deleted_at                 TIMESTAMP NOT NULL DEFAULT 'infinity'
+);
+
+INSERT INTO company (
+    company_id,
+    company_name,
+    company_cnpj,
+    company_logo,
+    company_created_at,
+    company_deleted_at
+) VALUES (
+    1,
+    'cordepimenta',
+    '00000000000001',
+    'https://placeholder.invalid/company/cordepimenta',
+    NOW(),
+    'infinity'
+);
+
+SELECT setval(
+    pg_get_serial_sequence('company', 'company_id'),
+    GREATEST((SELECT COALESCE(MAX(company_id), 1) FROM company), 1),
+    true
 );
 
 CREATE TABLE "user" (
     user_id                            SERIAL PRIMARY KEY,
+    company_id                         INT NOT NULL,
     user_name                          VARCHAR(100) NOT NULL,
     user_phone                         VARCHAR(20) NOT NULL,
     user_email                         VARCHAR(100) NOT NULL UNIQUE,
     user_role                          VARCHAR(10) NOT NULL,
     user_password                      VARCHAR(255) NOT NULL,
     user_created_at                    TIMESTAMP NOT NULL DEFAULT NOW(),
-    user_deleted_at                    TIMESTAMP NOT NULL DEFAULT 'delete',
+    user_deleted_at                    TIMESTAMP NOT NULL DEFAULT 'infinity',
     CONSTRAINT fk_users_company FOREIGN KEY (company_id) REFERENCES company(company_id)
 );
 
+INSERT INTO "user" (
+    company_id,
+    user_name,
+    user_phone,
+    user_email,
+    user_role,
+    user_password,
+    user_created_at,
+    user_deleted_at
+) VALUES (
+    1,
+    'Administrador',
+    '5500000000000',
+    'admin@mixtech.com',
+    'admin',
+    'admin123',
+    NOW(),
+    'infinity'
+);
+
 CREATE TABLE operator (
     operator_id                        SERIAL PRIMARY KEY,
+    company_id                         INT NOT NULL,
     operator_name                      VARCHAR(100) NOT NULL,
     operator_initials                  VARCHAR(5) NOT NULL,
     operator_email                     VARCHAR(100) NOT NULL,
@@ -33,7 +77,7 @@ CREATE TABLE operator (
     operator_status                    VARCHAR(30) NOT NULL DEFAULT 'Disponível',
     operator_available_for_escalation  BOOLEAN NOT NULL DEFAULT TRUE,
     operator_created_at                TIMESTAMP NOT NULL DEFAULT NOW(),
-    operator_deleted_at                TIMESTAMP NOT NULL DEFAULT 'delete',
+    operator_deleted_at                TIMESTAMP NOT NULL DEFAULT 'infinity',
     CONSTRAINT fk_operators_company FOREIGN KEY (company_id) REFERENCES company(company_id)
 );
 
@@ -42,34 +86,37 @@ CREATE TABLE operator_channel (
     operator_id                        INT NOT NULL,
     operator_channel                   VARCHAR(20) NOT NULL,
     operator_channel_created_at        TIMESTAMP NOT NULL DEFAULT NOW(),
-    operator_channel_deleted_at        TIMESTAMP NOT NULL DEFAULT 'delete',
+    operator_channel_deleted_at        TIMESTAMP NOT NULL DEFAULT 'infinity',
     CONSTRAINT fk_op_channel_operator FOREIGN KEY (operator_id) REFERENCES operator(operator_id)
 );
 
 CREATE TABLE sla_config (
     sla_config_id                      SERIAL PRIMARY KEY,
+    company_id                         INT NOT NULL,
     sla_config_department              VARCHAR(20) NOT NULL,
     sla_config_response_hours          INT NOT NULL DEFAULT 2,
     sla_config_resolution_hours        INT NOT NULL DEFAULT 24,
     sla_config_updated_at              TIMESTAMP NOT NULL DEFAULT NOW(),
-    sla_config_deleted_at              TIMESTAMP NOT NULL DEFAULT 'delete',
+    sla_config_deleted_at              TIMESTAMP NOT NULL DEFAULT 'infinity',
     CONSTRAINT fk_sla_company FOREIGN KEY (company_id) REFERENCES company(company_id),
     CONSTRAINT uq_sla_dept UNIQUE (company_id, sla_config_department)
 );
 
 CREATE TABLE sku (
     sku_id                             SERIAL PRIMARY KEY,
+    company_id                         INT NOT NULL,
     sku_name                           VARCHAR(100) NOT NULL,
     sku_value                          DECIMAL(12,2) NOT NULL,
     sku_sold                           INT NOT NULL DEFAULT 0,
     sku_line                           VARCHAR(50) NOT NULL,
     sku_created_at                     TIMESTAMP NOT NULL DEFAULT NOW(),
-    sku_deleted_at                     TIMESTAMP NOT NULL DEFAULT 'delete',
+    sku_deleted_at                     TIMESTAMP NOT NULL DEFAULT 'infinity',
     CONSTRAINT fk_skus_company FOREIGN KEY (company_id) REFERENCES company(company_id)
 );
 
 CREATE TABLE integration (
-    integration_id                     SERIAL PRIMARY KEY,    
+    integration_id                     SERIAL PRIMARY KEY,
+    company_id                         INT NOT NULL,
     integration_provider               VARCHAR(30) NOT NULL,
     integration_account_id             TEXT NOT NULL,
     integration_external_account_id    TEXT NOT NULL,
@@ -82,7 +129,7 @@ CREATE TABLE integration (
     integration_last_error             TEXT NOT NULL,
     integration_created_at             TIMESTAMP NOT NULL DEFAULT NOW(),
     integration_updated_at             TIMESTAMP NOT NULL DEFAULT NOW(),
-    integration_deleted_at             TIMESTAMP NOT NULL DEFAULT 'delete',
+    integration_deleted_at             TIMESTAMP NOT NULL DEFAULT 'infinity',
     CONSTRAINT fk_integrations_company FOREIGN KEY (company_id) REFERENCES company(company_id)
 );
 
@@ -93,6 +140,7 @@ CREATE TABLE integration (
 
 CREATE TABLE client (
     client_id                          SERIAL PRIMARY KEY,
+    company_id                         INT NOT NULL,
     client_provider_id                 TEXT NOT NULL,
     client_phone                       VARCHAR(20) NOT NULL,
     client_name                        VARCHAR(100) NOT NULL,
@@ -100,13 +148,14 @@ CREATE TABLE client (
     client_segment                     VARCHAR(100) NOT NULL,
     client_is_registered               BOOLEAN NOT NULL DEFAULT FALSE,
     client_created_at                  TIMESTAMP NOT NULL DEFAULT NOW(),
-    client_deleted_at                  TIMESTAMP NOT NULL DEFAULT 'delete',
+    client_deleted_at                  TIMESTAMP NOT NULL DEFAULT 'infinity',
     CONSTRAINT fk_clients_company FOREIGN KEY (company_id) REFERENCES company(company_id),
     CONSTRAINT uq_client_phone_company UNIQUE (company_id, client_phone)
 );
 
 CREATE TABLE conversation (
     conversation_id                    SERIAL PRIMARY KEY,
+    company_id                         INT NOT NULL,
     integration_id                     INT NOT NULL,
     operator_id                        INT NOT NULL,
     client_id                          INT NOT NULL,
@@ -125,7 +174,7 @@ CREATE TABLE conversation (
     conversation_ticket_value          DECIMAL(12,2) NOT NULL,
     conversation_conversion_chance     INT NOT NULL,
     conversation_optimum_window        VARCHAR(20) NOT NULL,
-    conversation_deleted_at            TIMESTAMP NOT NULL DEFAULT 'delete',
+    conversation_deleted_at            TIMESTAMP NOT NULL DEFAULT 'infinity',
     CONSTRAINT fk_conv_company FOREIGN KEY (company_id) REFERENCES company(company_id),
     CONSTRAINT fk_conv_integration FOREIGN KEY (integration_id) REFERENCES integration(integration_id),
     CONSTRAINT fk_conv_operator FOREIGN KEY (operator_id) REFERENCES operator(operator_id),
@@ -150,7 +199,7 @@ CREATE TABLE message (
     message_is_event                   BOOLEAN NOT NULL DEFAULT FALSE,
     message_event_type                 INT NOT NULL,
     message_sent_at                    TIMESTAMP NOT NULL DEFAULT NOW(),
-    message_deleted_at                 TIMESTAMP NOT NULL DEFAULT 'delete',
+    message_deleted_at                 TIMESTAMP NOT NULL DEFAULT 'infinity',
     CONSTRAINT fk_msg_conversation FOREIGN KEY (conversation_id) REFERENCES conversation(conversation_id),
     CONSTRAINT fk_quoted_message FOREIGN KEY (quoted_message_id) REFERENCES message(message_id)
 );
@@ -165,7 +214,7 @@ CREATE TABLE message_attachment (
     attachment_file_name               VARCHAR(255) NOT NULL,
     attachment_size                    BIGINT NOT NULL,
     attachment_created_at              TIMESTAMP NOT NULL DEFAULT NOW(),
-    attachment_deleted_at              TIMESTAMP NOT NULL DEFAULT 'delete',
+    attachment_deleted_at              TIMESTAMP NOT NULL DEFAULT 'infinity',
     CONSTRAINT fk_attachment_message FOREIGN KEY (message_id) REFERENCES message(message_id)
 );
 
@@ -176,7 +225,7 @@ CREATE TABLE message_reaction (
     reaction_value                     VARCHAR(20) NOT NULL,
     reaction_is_sender                 BOOLEAN NOT NULL DEFAULT FALSE,
     reaction_created_at                TIMESTAMP NOT NULL DEFAULT NOW(),
-    reaction_deleted_at                TIMESTAMP NOT NULL DEFAULT 'delete',
+    reaction_deleted_at                TIMESTAMP NOT NULL DEFAULT 'infinity',
     CONSTRAINT fk_reaction_message FOREIGN KEY (message_id) REFERENCES message(message_id)
 );
 
@@ -187,7 +236,7 @@ CREATE TABLE conversation_participant (
     participant_name                   VARCHAR(100) NOT NULL,
     participant_is_admin               BOOLEAN NOT NULL DEFAULT FALSE,
     participant_created_at             TIMESTAMP NOT NULL DEFAULT NOW(),
-    participant_deleted_at             TIMESTAMP NOT NULL DEFAULT 'delete',
+    participant_deleted_at             TIMESTAMP NOT NULL DEFAULT 'infinity',
     CONSTRAINT fk_participant_conversation FOREIGN KEY (conversation_id) REFERENCES conversation(conversation_id)
 );
 
@@ -200,7 +249,7 @@ CREATE TABLE webhook_event (
     webhook_event_processed            BOOLEAN NOT NULL DEFAULT FALSE,
     webhook_event_received_at          TIMESTAMP NOT NULL DEFAULT NOW(),
     webhook_event_processed_at         TIMESTAMP NOT NULL,
-    webhook_event_deleted_at           TIMESTAMP NOT NULL DEFAULT 'delete',
+    webhook_event_deleted_at           TIMESTAMP NOT NULL DEFAULT 'infinity',
     CONSTRAINT fk_webhook_integration FOREIGN KEY (integration_id) REFERENCES integration(integration_id)
 );
 
@@ -212,32 +261,35 @@ CREATE TABLE webhook_event (
 CREATE TABLE conversation_analysis (
     analysis_id                                SERIAL PRIMARY KEY,
     conversation_id                            INT NOT NULL UNIQUE,
+    company_id                                 INT NOT NULL,
     
     conversation_analysis_sentiment            VARCHAR(20) NOT NULL,
     conversation_analysis_sentiment_score      NUMERIC(3,2) NOT NULL,
     conversation_analysis_aspect               VARCHAR(50) NOT NULL,
     conversation_analysis_sub_aspect           VARCHAR(100) NOT NULL,
     conversation_analysis_analyzed_at          TIMESTAMP NOT NULL DEFAULT NOW(),
-    conversation_analysis_deleted_at           TIMESTAMP NOT NULL DEFAULT 'delete',
+    conversation_analysis_deleted_at           TIMESTAMP NOT NULL DEFAULT 'infinity',
     CONSTRAINT fk_analysis_conv FOREIGN KEY (conversation_id) REFERENCES conversation(conversation_id),
     CONSTRAINT fk_analysis_company FOREIGN KEY (company_id) REFERENCES company(company_id)
 );
 
 CREATE TABLE aspect_feedback (
     aspect_feedback_id                         SERIAL PRIMARY KEY,
+    company_id                                 INT NOT NULL,
     
     conversation_id                            INT NOT NULL,
     aspect_feedback_aspect                     VARCHAR(50) NOT NULL,
     aspect_feedback_sentiment                  VARCHAR(20) NOT NULL,
     aspect_feedback_text                       TEXT NOT NULL,
     aspect_feedback_created_at                 TIMESTAMP NOT NULL DEFAULT NOW(),
-    aspect_feedback_deleted_at                 TIMESTAMP NOT NULL DEFAULT 'delete',
+    aspect_feedback_deleted_at                 TIMESTAMP NOT NULL DEFAULT 'infinity',
     CONSTRAINT fk_af_company FOREIGN KEY (company_id) REFERENCES company(company_id),
     CONSTRAINT fk_af_conv FOREIGN KEY (conversation_id) REFERENCES conversation(conversation_id)
 );
 
 CREATE TABLE emotion_snapshot (
     emotion_id                                 SERIAL PRIMARY KEY,
+    company_id                                 INT NOT NULL,
     emotion_snapshot_date                      DATE NOT NULL,
     emotion_happiness                          NUMERIC(5,2) NOT NULL DEFAULT 0,
     emotion_sadness                            NUMERIC(5,2) NOT NULL DEFAULT 0,
@@ -253,16 +305,18 @@ CREATE TABLE emotion_snapshot (
 
 CREATE TABLE public_opinion (
     opinion_id                                 SERIAL PRIMARY KEY,
+    company_id                                 INT NOT NULL,
     conversation_id                            INT NOT NULL,
     opinion_is_positive                        BOOLEAN NOT NULL,
     opinion_classified_at                      TIMESTAMP NOT NULL DEFAULT NOW(),
-    opinion_deleted_at                         TIMESTAMP NOT NULL DEFAULT 'delete',
+    opinion_deleted_at                         TIMESTAMP NOT NULL DEFAULT 'infinity',
     CONSTRAINT fk_opinion_company FOREIGN KEY (company_id) REFERENCES company(company_id),
     CONSTRAINT fk_opinion_conv FOREIGN KEY (conversation_id) REFERENCES conversation(conversation_id)
 );
 
 CREATE TABLE alert (
     alert_id                                   SERIAL PRIMARY KEY,
+    company_id                                 INT NOT NULL,
     client_id                                  INT NOT NULL,
     alert_type                                 VARCHAR(20) NOT NULL,
     alert_priority                             VARCHAR(10) NOT NULL,
@@ -271,18 +325,19 @@ CREATE TABLE alert (
     alert_tips                                 TEXT NOT NULL,
     alert_is_resolved                          BOOLEAN NOT NULL DEFAULT FALSE,
     alert_created_at                           TIMESTAMP NOT NULL DEFAULT NOW(),
-    alert_deleted_at                           TIMESTAMP NOT NULL DEFAULT 'delete',
+    alert_deleted_at                           TIMESTAMP NOT NULL DEFAULT 'infinity',
     CONSTRAINT fk_alert_company FOREIGN KEY (company_id) REFERENCES company(company_id),
     CONSTRAINT fk_alert_client FOREIGN KEY (client_id) REFERENCES client(client_id)
 );
 
 CREATE TABLE ai_action (
     ai_action_id                               SERIAL PRIMARY KEY,
+    company_id                                 INT NOT NULL,
     ai_action_idea                             TEXT NOT NULL,
     ai_action_is_accepted                      BOOLEAN NOT NULL,
     ai_action_created_at                       TIMESTAMP NOT NULL DEFAULT NOW(),
     ai_action_responded_at                     TIMESTAMP NOT NULL,
-    ai_action_deleted_at                       TIMESTAMP NOT NULL DEFAULT 'delete',
+    ai_action_deleted_at                       TIMESTAMP NOT NULL DEFAULT 'infinity',
     CONSTRAINT fk_ai_action_company FOREIGN KEY (company_id) REFERENCES company(company_id)
 );
 
@@ -293,6 +348,7 @@ CREATE TABLE ai_action (
 
 CREATE TABLE persona (
     persona_id                                 SERIAL PRIMARY KEY,
+    company_id                                 INT NOT NULL,
     persona_name                               VARCHAR(100) NOT NULL,
     persona_type                               VARCHAR(50) NOT NULL DEFAULT 'O PERFIL',
     persona_description                        TEXT NOT NULL,
@@ -303,7 +359,7 @@ CREATE TABLE persona (
     persona_expansion_strategy                 TEXT NOT NULL,
     persona_engagement_strategy                TEXT NOT NULL,
     persona_created_at                         TIMESTAMP NOT NULL DEFAULT NOW(),
-    persona_deleted_at                         TIMESTAMP NOT NULL DEFAULT 'delete',
+    persona_deleted_at                         TIMESTAMP NOT NULL DEFAULT 'infinity',
     CONSTRAINT fk_personas_company FOREIGN KEY (company_id) REFERENCES company(company_id)
 );
 
@@ -312,18 +368,19 @@ CREATE TABLE client_persona (
     client_id                                  INT NOT NULL,
     persona_id                                 INT NOT NULL,
     client_persona_assigned_at                 TIMESTAMP NOT NULL DEFAULT NOW(),
-    client_persona_deleted_at                  TIMESTAMP NOT NULL DEFAULT 'delete',
+    client_persona_deleted_at                  TIMESTAMP NOT NULL DEFAULT 'infinity',
     CONSTRAINT fk_cp_client FOREIGN KEY (client_id) REFERENCES client(client_id),
     CONSTRAINT fk_cp_persona FOREIGN KEY (persona_id) REFERENCES persona(persona_id)
 );
 
 CREATE TABLE best_action (
     best_action_id                             SERIAL PRIMARY KEY,
+    company_id                                 INT NOT NULL,
     persona_id                                 INT NOT NULL,
     best_action_type                           VARCHAR(20) NOT NULL,
     best_action_idea                           TEXT NOT NULL,
     best_action_created_at                     TIMESTAMP NOT NULL DEFAULT NOW(),
-    best_action_deleted_at                     TIMESTAMP NOT NULL DEFAULT 'delete',
+    best_action_deleted_at                     TIMESTAMP NOT NULL DEFAULT 'infinity',
     CONSTRAINT fk_ba_company FOREIGN KEY (company_id) REFERENCES company(company_id),
     CONSTRAINT fk_ba_persona FOREIGN KEY (persona_id) REFERENCES persona(persona_id)
 );
@@ -335,6 +392,7 @@ CREATE TABLE best_action (
 
 CREATE TABLE volume_snapshot (
     volume_id                                  SERIAL PRIMARY KEY,
+    company_id                                 INT NOT NULL,
     volume_snapshot_date                       DATE NOT NULL,
     volume_channel                             VARCHAR(20) NOT NULL,
     volume_message_count                       INT NOT NULL DEFAULT 0,
@@ -345,6 +403,7 @@ CREATE TABLE volume_snapshot (
 
 CREATE TABLE sentiment_evolution (
     evolution_id                               SERIAL PRIMARY KEY,
+    company_id                                 INT NOT NULL,
     evolution_snapshot_date                    DATE NOT NULL,
     evolution_sentiment_score                  NUMERIC(3,2) NOT NULL,
     CONSTRAINT fk_evo_company FOREIGN KEY (company_id) REFERENCES company(company_id),
@@ -353,6 +412,7 @@ CREATE TABLE sentiment_evolution (
 
 CREATE TABLE playbooks_monitor (
     playbook_id                                SERIAL PRIMARY KEY,
+    company_id                                 INT NOT NULL,
     playbook_snapshot_date                     DATE NOT NULL,
     playbook_new_detected                      INT NOT NULL DEFAULT 0,
     playbook_converted                         INT NOT NULL DEFAULT 0,
@@ -363,6 +423,7 @@ CREATE TABLE playbooks_monitor (
 
 CREATE TABLE operator_daily_stats (
     stat_id                                    SERIAL PRIMARY KEY,
+    company_id                                 INT NOT NULL,
     operator_id                                INT NOT NULL,
     operator_stat_date                         DATE NOT NULL,
     operator_attendances_count                 INT NOT NULL DEFAULT 0,
@@ -376,6 +437,7 @@ CREATE TABLE operator_daily_stats (
 
 CREATE TABLE kpi_snapshot (
     kpi_id                                     SERIAL PRIMARY KEY,
+    company_id                                 INT NOT NULL,
     kpi_snapshot_date                          DATE NOT NULL,
     kpi_current_sales                          DECIMAL(12,2) NOT NULL DEFAULT 0,
     kpi_avg_ticket                             DECIMAL(12,2) NOT NULL DEFAULT 0,
@@ -393,4 +455,4 @@ CREATE TABLE kpi_snapshot (
     kpi_general_emotion                        VARCHAR(20) NOT NULL,
     CONSTRAINT fk_kpi_company FOREIGN KEY (company_id) REFERENCES company(company_id),
     CONSTRAINT uq_kpi_date UNIQUE (company_id, kpi_snapshot_date)
-);
+);

+ 58 - 0
migrations/migrations_v2_unipile.sql

@@ -0,0 +1,58 @@
+ALTER TABLE integration
+    ADD COLUMN IF NOT EXISTS user_id INT NOT NULL DEFAULT 0,
+    ADD COLUMN IF NOT EXISTS operator_id INT NOT NULL DEFAULT 0,
+    ADD COLUMN IF NOT EXISTS integration_metadata JSONB NOT NULL DEFAULT '{}'::jsonb;
+
+ALTER TABLE conversation
+    ALTER COLUMN conversation_closed_at SET DEFAULT 'infinity',
+    ALTER COLUMN conversation_sla_deadline SET DEFAULT 'infinity',
+    ALTER COLUMN conversation_last_message_at SET DEFAULT NOW(),
+    ALTER COLUMN conversation_last_message_preview SET DEFAULT '',
+    ALTER COLUMN conversation_last_message_from SET DEFAULT 'client',
+    ALTER COLUMN conversation_impact_value SET DEFAULT 0,
+    ALTER COLUMN conversation_ticket_value SET DEFAULT 0,
+    ALTER COLUMN conversation_conversion_chance SET DEFAULT 0,
+    ALTER COLUMN conversation_optimum_window SET DEFAULT '';
+
+ALTER TABLE message
+    DROP CONSTRAINT IF EXISTS fk_quoted_message;
+
+ALTER TABLE message
+    ALTER COLUMN quoted_message_id SET DEFAULT 0,
+    ALTER COLUMN message_provider_id SET DEFAULT '',
+    ALTER COLUMN message_sender_provider_id SET DEFAULT '',
+    ALTER COLUMN message_content SET DEFAULT '',
+    ALTER COLUMN message_seen SET DEFAULT FALSE,
+    ALTER COLUMN message_delivered SET DEFAULT FALSE,
+    ALTER COLUMN message_event_type SET DEFAULT 0;
+
+ALTER TABLE webhook_event
+    ALTER COLUMN webhook_event_processed_at SET DEFAULT NOW();
+
+CREATE UNIQUE INDEX IF NOT EXISTS uq_integration_account_active
+    ON integration (integration_account_id)
+    WHERE integration_deleted_at = 'infinity';
+
+CREATE UNIQUE INDEX IF NOT EXISTS uq_client_provider_company_active
+    ON client (company_id, client_provider_id)
+    WHERE client_deleted_at = 'infinity';
+
+ALTER TABLE conversation
+    ADD CONSTRAINT uq_conversation_integration_external_deleted
+    UNIQUE (integration_id, conversation_external_id, conversation_deleted_at);
+
+ALTER TABLE message
+    ADD CONSTRAINT uq_message_conversation_external_deleted
+    UNIQUE (conversation_id, message_external_id, message_deleted_at);
+
+ALTER TABLE message_attachment
+    ADD CONSTRAINT uq_attachment_message_external_deleted
+    UNIQUE (message_id, attachment_external_id, attachment_deleted_at);
+
+ALTER TABLE conversation_participant
+    ADD CONSTRAINT uq_participant_conversation_provider_deleted
+    UNIQUE (conversation_id, participant_provider_id, participant_deleted_at);
+
+ALTER TABLE webhook_event
+    ADD CONSTRAINT uq_webhook_integration_event_external_deleted
+    UNIQUE (integration_id, webhook_event_type, webhook_event_external_id, webhook_event_deleted_at);

+ 230 - 0
models/IntegrationsModel.php

@@ -0,0 +1,230 @@
+<?php
+
+namespace Models;
+
+use Libs\Database;
+
+class IntegrationsModel
+{
+    private \PDO $pdo;
+
+    public function __construct()
+    {
+        $this->pdo = Database::pdo();
+    }
+
+    public function getOperatorIdByUserEmail(int $companyId, string $email): int
+    {
+        $normalizedEmail = mb_strtolower(trim($email));
+        if ($normalizedEmail === '') {
+            return 0;
+        }
+
+        $stmt = $this->pdo->prepare(
+            "SELECT operator_id
+            FROM operator
+            WHERE company_id = :company_id
+              AND lower(operator_email) = :email
+              AND operator_deleted_at = 'infinity'
+            LIMIT 1"
+        );
+        $stmt->execute([
+            'company_id' => $companyId,
+            'email' => $normalizedEmail,
+        ]);
+
+        $operatorId = $stmt->fetchColumn();
+
+        return $operatorId === false ? 0 : (int) $operatorId;
+    }
+
+    public function listByCompany(int $companyId): array
+    {
+        $stmt = $this->pdo->prepare(
+            "SELECT integration_id, company_id, user_id, operator_id, integration_provider,
+                    integration_account_id, integration_external_account_id, integration_account_name,
+                    integration_status, integration_is_connected, integration_last_sync_at,
+                    integration_last_error, integration_created_at, integration_updated_at
+            FROM integration
+            WHERE company_id = :company_id
+              AND integration_provider = 'whatsapp'
+              AND integration_deleted_at = 'infinity'
+            ORDER BY integration_created_at DESC, integration_id DESC"
+        );
+        $stmt->execute(['company_id' => $companyId]);
+        $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
+
+        return array_map([$this, 'formatIntegration'], $rows);
+    }
+
+    public function findById(int $companyId, int $integrationId): ?array
+    {
+        $stmt = $this->pdo->prepare(
+            "SELECT *
+            FROM integration
+            WHERE company_id = :company_id
+              AND integration_id = :integration_id
+              AND integration_deleted_at = 'infinity'
+            LIMIT 1"
+        );
+        $stmt->execute([
+            'company_id' => $companyId,
+            'integration_id' => $integrationId,
+        ]);
+        $row = $stmt->fetch(\PDO::FETCH_ASSOC);
+
+        return $row === false ? null : $row;
+    }
+
+    public function findByAccountId(string $accountId): ?array
+    {
+        $stmt = $this->pdo->prepare(
+            "SELECT *
+            FROM integration
+            WHERE integration_account_id = :account_id
+              AND integration_deleted_at = 'infinity'
+            LIMIT 1"
+        );
+        $stmt->execute(['account_id' => $accountId]);
+        $row = $stmt->fetch(\PDO::FETCH_ASSOC);
+
+        return $row === false ? null : $row;
+    }
+
+    public function upsertWhatsappIntegration(int $companyId, int $userId, int $operatorId, string $accountId, string $status, array $metadata = []): array
+    {
+        $externalAccountId = $this->extractExternalAccountId($metadata);
+        $accountName = $this->extractAccountName($metadata, $accountId);
+        $isConnected = in_array($status, ['OK', 'CREATION_SUCCESS', 'RECONNECTED', 'SYNC_SUCCESS', 'CONNECTING'], true);
+        $metadataJson = json_encode($metadata, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+        if ($metadataJson === false) {
+            $metadataJson = '{}';
+        }
+
+        $existing = $this->findByAccountId($accountId);
+        if ($existing !== null) {
+            $stmt = $this->pdo->prepare(
+                "UPDATE integration
+                SET company_id = :company_id,
+                    user_id = :user_id,
+                    operator_id = :operator_id,
+                    integration_external_account_id = :external_account_id,
+                    integration_account_name = :account_name,
+                    integration_status = :status,
+                    integration_is_connected = :is_connected,
+                    integration_last_sync_at = NOW(),
+                    integration_last_error = '',
+                    integration_metadata = CAST(:metadata AS jsonb),
+                    integration_updated_at = NOW()
+                WHERE integration_id = :integration_id
+                RETURNING *"
+            );
+            $stmt->execute([
+                'company_id' => $companyId,
+                'user_id' => $userId,
+                'operator_id' => $operatorId,
+                'external_account_id' => $externalAccountId,
+                'account_name' => $accountName,
+                'status' => $status,
+                'is_connected' => $isConnected,
+                'metadata' => $metadataJson,
+                'integration_id' => (int) $existing['integration_id'],
+            ]);
+
+            return $stmt->fetch(\PDO::FETCH_ASSOC) ?: $existing;
+        }
+
+        $stmt = $this->pdo->prepare(
+            "INSERT INTO integration (
+                company_id, user_id, operator_id, integration_provider, integration_account_id,
+                integration_external_account_id, integration_account_name, integration_status,
+                integration_access_token, integration_refresh_token, integration_is_connected,
+                integration_last_sync_at, integration_last_error, integration_metadata
+            ) VALUES (
+                :company_id, :user_id, :operator_id, 'whatsapp', :account_id,
+                :external_account_id, :account_name, :status,
+                '', '', :is_connected,
+                NOW(), '', CAST(:metadata AS jsonb)
+            ) RETURNING *"
+        );
+        $stmt->execute([
+            'company_id' => $companyId,
+            'user_id' => $userId,
+            'operator_id' => $operatorId,
+            'account_id' => $accountId,
+            'external_account_id' => $externalAccountId,
+            'account_name' => $accountName,
+            'status' => $status,
+            'is_connected' => $isConnected,
+            'metadata' => $metadataJson,
+        ]);
+
+        return $stmt->fetch(\PDO::FETCH_ASSOC) ?: [];
+    }
+
+    public function updateStatusByAccountId(string $accountId, string $status, array $metadata = []): ?array
+    {
+        $isConnected = in_array($status, ['OK', 'CREATION_SUCCESS', 'RECONNECTED', 'SYNC_SUCCESS', 'CONNECTING'], true);
+        $metadataJson = json_encode($metadata, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+        if ($metadataJson === false) {
+            $metadataJson = '{}';
+        }
+
+        $stmt = $this->pdo->prepare(
+            "UPDATE integration
+            SET integration_status = :status,
+                integration_is_connected = :is_connected,
+                integration_last_sync_at = NOW(),
+                integration_last_error = CASE WHEN :is_connected THEN '' ELSE integration_last_error END,
+                integration_metadata = integration_metadata || CAST(:metadata AS jsonb),
+                integration_updated_at = NOW()
+            WHERE integration_account_id = :account_id
+              AND integration_deleted_at = 'infinity'
+            RETURNING *"
+        );
+        $stmt->execute([
+            'status' => $status,
+            'is_connected' => $isConnected,
+            'metadata' => $metadataJson,
+            'account_id' => $accountId,
+        ]);
+        $row = $stmt->fetch(\PDO::FETCH_ASSOC);
+
+        return $row === false ? null : $row;
+    }
+
+    public function formatIntegration(array $row): array
+    {
+        return [
+            'id' => (int) ($row['integration_id'] ?? 0),
+            'companyId' => (int) ($row['company_id'] ?? 0),
+            'userId' => (int) ($row['user_id'] ?? 0),
+            'operatorId' => (int) ($row['operator_id'] ?? 0),
+            'provider' => $row['integration_provider'] ?? '',
+            'accountId' => $row['integration_account_id'] ?? '',
+            'externalAccountId' => $row['integration_external_account_id'] ?? '',
+            'accountName' => $row['integration_account_name'] ?? '',
+            'status' => $row['integration_status'] ?? '',
+            'isConnected' => (bool) ($row['integration_is_connected'] ?? false),
+            'lastSyncAt' => $row['integration_last_sync_at'] ?? '',
+            'lastError' => $row['integration_last_error'] ?? '',
+            'createdAt' => $row['integration_created_at'] ?? '',
+            'updatedAt' => $row['integration_updated_at'] ?? '',
+        ];
+    }
+
+    private function extractExternalAccountId(array $metadata): string
+    {
+        $accountInfo = is_array($metadata['account_info'] ?? null) ? $metadata['account_info'] : [];
+
+        return (string) ($accountInfo['user_id'] ?? $metadata['external_account_id'] ?? '');
+    }
+
+    private function extractAccountName(array $metadata, string $accountId): string
+    {
+        $accountInfo = is_array($metadata['account_info'] ?? null) ? $metadata['account_info'] : [];
+        $name = trim((string) ($accountInfo['name'] ?? $metadata['account_name'] ?? $metadata['name'] ?? ''));
+
+        return $name !== '' ? mb_substr($name, 0, 100) : mb_substr($accountId, 0, 100);
+    }
+}

+ 500 - 0
models/UnipileMessagesModel.php

@@ -0,0 +1,500 @@
+<?php
+
+namespace Models;
+
+use Libs\Database;
+
+class UnipileMessagesModel
+{
+    private \PDO $pdo;
+
+    public function __construct()
+    {
+        $this->pdo = Database::pdo();
+    }
+
+    public function storeWebhookEvent(int $integrationId, string $eventType, string $externalId, array $payload, bool $processed): int
+    {
+        $payloadJson = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+        if ($payloadJson === false) {
+            $payloadJson = '{}';
+        }
+
+        $stmt = $this->pdo->prepare(
+            "INSERT INTO webhook_event (
+                integration_id, webhook_event_type, webhook_event_external_id,
+                webhook_event_payload, webhook_event_processed, webhook_event_processed_at
+            ) VALUES (
+                :integration_id, :event_type, :external_id,
+                CAST(:payload AS jsonb), :processed, NOW()
+            )
+            ON CONFLICT (integration_id, webhook_event_type, webhook_event_external_id, webhook_event_deleted_at)
+            DO UPDATE SET
+                webhook_event_payload = EXCLUDED.webhook_event_payload,
+                webhook_event_processed = EXCLUDED.webhook_event_processed,
+                webhook_event_processed_at = NOW()
+            RETURNING webhook_event_id"
+        );
+        $stmt->execute([
+            'integration_id' => $integrationId,
+            'event_type' => mb_substr($eventType, 0, 50),
+            'external_id' => $externalId,
+            'payload' => $payloadJson,
+            'processed' => $processed,
+        ]);
+
+        return (int) $stmt->fetchColumn();
+    }
+
+    public function upsertMessageFromWebhook(array $integration, array $payload): ?array
+    {
+        $accountType = mb_strtoupper(trim((string) ($payload['account_type'] ?? '')));
+        if ($accountType !== 'WHATSAPP') {
+            return null;
+        }
+
+        $chatId = trim((string) ($payload['chat_id'] ?? ''));
+        $messageId = trim((string) ($payload['message_id'] ?? ''));
+        if ($chatId === '' || $messageId === '') {
+            return null;
+        }
+
+        $companyId = (int) ($integration['company_id'] ?? 0);
+        $integrationId = (int) ($integration['integration_id'] ?? 0);
+        $operatorId = $this->resolveOperatorId($companyId, (int) ($integration['operator_id'] ?? 0));
+        if ($companyId <= 0 || $integrationId <= 0 || $operatorId <= 0) {
+            return null;
+        }
+
+        $sender = is_array($payload['sender'] ?? null) ? $payload['sender'] : [];
+        $attendees = is_array($payload['attendees'] ?? null) ? $payload['attendees'] : [];
+        $senderProviderId = $this->value($sender, ['attendee_provider_id', 'sender_id', 'id'], 'unknown');
+        $isOperator = $this->isOperatorMessage($integration, $payload, $senderProviderId);
+        [$clientProviderId, $clientName] = $this->resolveClientIdentity($integration, $payload, $sender, $attendees, $senderProviderId, $isOperator);
+        $client = $this->upsertClient($companyId, $clientProviderId, $clientName);
+        $messageText = (string) ($payload['message'] ?? $payload['text'] ?? '');
+        $sentAt = $this->normalizeTimestamp((string) ($payload['timestamp'] ?? ''));
+        $conversation = $this->upsertConversation($companyId, $integrationId, $operatorId, (int) $client['client_id'], $chatId, $payload, $messageText, $isOperator, $sentAt);
+        $message = $this->upsertMessage((int) $conversation['conversation_id'], $messageId, $payload, $senderProviderId, $isOperator, $messageText, $sentAt);
+        $this->replaceParticipants((int) $conversation['conversation_id'], $attendees);
+        $this->replaceAttachments((int) $message['message_id'], is_array($payload['attachments'] ?? null) ? $payload['attachments'] : []);
+
+        return [
+            'conversation_id' => (int) $conversation['conversation_id'],
+            'message_id' => (int) $message['message_id'],
+        ];
+    }
+
+    public function createOutboundLocalMessage(int $conversationId, array $unipileResponse, string $fallbackText): array
+    {
+        $messageId = trim((string) ($unipileResponse['id'] ?? $unipileResponse['message_id'] ?? ''));
+        if ($messageId === '') {
+            $messageId = 'local-' . bin2hex(random_bytes(12));
+        }
+
+        $payload = [
+            'provider_id' => $unipileResponse['provider_id'] ?? '',
+            'seen' => $unipileResponse['seen'] ?? false,
+            'delivered' => $unipileResponse['delivered'] ?? true,
+            'edited' => $unipileResponse['edited'] ?? false,
+            'deleted' => $unipileResponse['deleted'] ?? false,
+            'hidden' => $unipileResponse['hidden'] ?? false,
+            'is_event' => $unipileResponse['is_event'] ?? false,
+            'event_type' => $unipileResponse['event_type'] ?? 0,
+        ];
+
+        return $this->upsertMessage(
+            $conversationId,
+            $messageId,
+            $payload,
+            (string) ($unipileResponse['sender_id'] ?? ''),
+            true,
+            (string) ($unipileResponse['text'] ?? $unipileResponse['message'] ?? $fallbackText),
+            $this->normalizeTimestamp((string) ($unipileResponse['timestamp'] ?? ''))
+        );
+    }
+
+    public function getConversationForSending(int $companyId, int $conversationId): ?array
+    {
+        $stmt = $this->pdo->prepare(
+            "SELECT c.*, i.integration_account_id, i.integration_is_connected, i.integration_status
+            FROM conversation c
+            INNER JOIN integration i
+                ON i.integration_id = c.integration_id
+               AND i.integration_deleted_at = 'infinity'
+            WHERE c.company_id = :company_id
+              AND c.conversation_id = :conversation_id
+              AND c.conversation_deleted_at = 'infinity'
+              AND i.integration_provider = 'whatsapp'
+            LIMIT 1"
+        );
+        $stmt->execute([
+            'company_id' => $companyId,
+            'conversation_id' => $conversationId,
+        ]);
+        $row = $stmt->fetch(\PDO::FETCH_ASSOC);
+
+        return $row === false ? null : $row;
+    }
+
+    public function updateConversationAfterOutbound(int $conversationId, string $text): void
+    {
+        $stmt = $this->pdo->prepare(
+            "UPDATE conversation
+            SET conversation_last_message_at = NOW(),
+                conversation_last_message_preview = :preview,
+                conversation_last_message_from = 'operator'
+            WHERE conversation_id = :conversation_id
+              AND conversation_deleted_at = 'infinity'"
+        );
+        $stmt->execute([
+            'preview' => mb_substr($text, 0, 500),
+            'conversation_id' => $conversationId,
+        ]);
+    }
+
+    private function upsertClient(int $companyId, string $providerId, string $name): array
+    {
+        $providerId = trim($providerId) !== '' ? trim($providerId) : 'unknown';
+        $phone = $this->normalizePhone($providerId);
+        $name = trim($name) !== '' ? mb_substr(trim($name), 0, 100) : $phone;
+
+        $stmt = $this->pdo->prepare(
+            "SELECT *
+            FROM client
+            WHERE company_id = :company_id
+              AND client_deleted_at = 'infinity'
+              AND (client_provider_id = :provider_id OR client_phone = :phone)
+            ORDER BY CASE WHEN client_provider_id = :provider_id THEN 0 ELSE 1 END
+            LIMIT 1"
+        );
+        $stmt->execute([
+            'company_id' => $companyId,
+            'provider_id' => $providerId,
+            'phone' => $phone,
+        ]);
+        $existing = $stmt->fetch(\PDO::FETCH_ASSOC);
+
+        if ($existing !== false) {
+            $update = $this->pdo->prepare(
+                "UPDATE client
+                SET client_provider_id = :provider_id,
+                    client_name = :name
+                WHERE client_id = :client_id
+                RETURNING *"
+            );
+            $update->execute([
+                'provider_id' => $providerId,
+                'name' => $name,
+                'client_id' => (int) $existing['client_id'],
+            ]);
+
+            return $update->fetch(\PDO::FETCH_ASSOC) ?: $existing;
+        }
+
+        $insert = $this->pdo->prepare(
+            "INSERT INTO client (
+                company_id, client_provider_id, client_phone, client_name,
+                client_email, client_segment, client_is_registered
+            ) VALUES (
+                :company_id, :provider_id, :phone, :name,
+                '', '', FALSE
+            ) RETURNING *"
+        );
+        $insert->execute([
+            'company_id' => $companyId,
+            'provider_id' => $providerId,
+            'phone' => $phone,
+            'name' => $name,
+        ]);
+
+        return $insert->fetch(\PDO::FETCH_ASSOC) ?: [];
+    }
+
+    private function upsertConversation(int $companyId, int $integrationId, int $operatorId, int $clientId, string $chatId, array $payload, string $messageText, bool $isOperator, string $sentAt): array
+    {
+        $providerChatId = (string) ($payload['chat_provider_id'] ?? $payload['provider_chat_id'] ?? '');
+        $lastFrom = $isOperator ? 'operator' : 'client';
+
+        $stmt = $this->pdo->prepare(
+            "INSERT INTO conversation (
+                company_id, integration_id, operator_id, client_id, conversation_external_id,
+                conversation_provider_id, conversation_channel, conversation_status, conversation_is_automated,
+                conversation_started_at, conversation_closed_at, conversation_sla_deadline,
+                conversation_last_message_at, conversation_last_message_preview, conversation_last_message_from,
+                conversation_impact_value, conversation_ticket_value, conversation_conversion_chance,
+                conversation_optimum_window
+            ) VALUES (
+                :company_id, :integration_id, :operator_id, :client_id, :external_id,
+                :provider_id, 'whatsapp', 'open', FALSE,
+                :sent_at, 'infinity', 'infinity',
+                :sent_at, :preview, :last_from,
+                0, 0, 0,
+                ''
+            )
+            ON CONFLICT (integration_id, conversation_external_id, conversation_deleted_at)
+            DO UPDATE SET
+                operator_id = EXCLUDED.operator_id,
+                client_id = EXCLUDED.client_id,
+                conversation_provider_id = CASE WHEN EXCLUDED.conversation_provider_id <> '' THEN EXCLUDED.conversation_provider_id ELSE conversation.conversation_provider_id END,
+                conversation_last_message_at = GREATEST(conversation.conversation_last_message_at, EXCLUDED.conversation_last_message_at),
+                conversation_last_message_preview = EXCLUDED.conversation_last_message_preview,
+                conversation_last_message_from = EXCLUDED.conversation_last_message_from
+            RETURNING *"
+        );
+        $stmt->execute([
+            'company_id' => $companyId,
+            'integration_id' => $integrationId,
+            'operator_id' => $operatorId,
+            'client_id' => $clientId,
+            'external_id' => $chatId,
+            'provider_id' => $providerChatId,
+            'sent_at' => $sentAt,
+            'preview' => mb_substr($messageText, 0, 500),
+            'last_from' => $lastFrom,
+        ]);
+
+        return $stmt->fetch(\PDO::FETCH_ASSOC) ?: [];
+    }
+
+    private function upsertMessage(int $conversationId, string $messageId, array $payload, string $senderProviderId, bool $isOperator, string $text, string $sentAt): array
+    {
+        $stmt = $this->pdo->prepare(
+            "INSERT INTO message (
+                conversation_id, quoted_message_id, message_external_id, message_provider_id,
+                message_sender_provider_id, message_is_operator, message_type, message_content,
+                message_seen, message_delivered, message_edited, message_deleted, message_hidden,
+                message_is_event, message_event_type, message_sent_at
+            ) VALUES (
+                :conversation_id, 0, :external_id, :provider_id,
+                :sender_provider_id, :is_operator, :type, :content,
+                :seen, :delivered, :edited, :deleted, :hidden,
+                :is_event, :event_type, :sent_at
+            )
+            ON CONFLICT (conversation_id, message_external_id, message_deleted_at)
+            DO UPDATE SET
+                message_provider_id = EXCLUDED.message_provider_id,
+                message_sender_provider_id = EXCLUDED.message_sender_provider_id,
+                message_is_operator = EXCLUDED.message_is_operator,
+                message_type = EXCLUDED.message_type,
+                message_content = EXCLUDED.message_content,
+                message_seen = EXCLUDED.message_seen,
+                message_delivered = EXCLUDED.message_delivered,
+                message_edited = EXCLUDED.message_edited,
+                message_deleted = EXCLUDED.message_deleted,
+                message_hidden = EXCLUDED.message_hidden,
+                message_is_event = EXCLUDED.message_is_event,
+                message_event_type = EXCLUDED.message_event_type,
+                message_sent_at = EXCLUDED.message_sent_at
+            RETURNING *"
+        );
+        $stmt->execute([
+            'conversation_id' => $conversationId,
+            'external_id' => $messageId,
+            'provider_id' => (string) ($payload['provider_id'] ?? ''),
+            'sender_provider_id' => $senderProviderId,
+            'is_operator' => $isOperator,
+            'type' => mb_substr((string) ($payload['type'] ?? 'text'), 0, 20),
+            'content' => $text,
+            'seen' => (bool) ($payload['seen'] ?? false),
+            'delivered' => (bool) ($payload['delivered'] ?? false),
+            'edited' => (bool) ($payload['edited'] ?? false),
+            'deleted' => (bool) ($payload['deleted'] ?? false),
+            'hidden' => (bool) ($payload['hidden'] ?? false),
+            'is_event' => (bool) ($payload['is_event'] ?? false),
+            'event_type' => (int) ($payload['event_type'] ?? 0),
+            'sent_at' => $sentAt,
+        ]);
+
+        return $stmt->fetch(\PDO::FETCH_ASSOC) ?: [];
+    }
+
+    private function replaceParticipants(int $conversationId, array $attendees): void
+    {
+        foreach ($attendees as $attendee) {
+            if (!is_array($attendee)) {
+                continue;
+            }
+
+            $providerId = $this->value($attendee, ['attendee_provider_id', 'id'], '');
+            if ($providerId === '') {
+                continue;
+            }
+
+            $stmt = $this->pdo->prepare(
+                "INSERT INTO conversation_participant (
+                    conversation_id, participant_provider_id, participant_name, participant_is_admin
+                ) VALUES (
+                    :conversation_id, :provider_id, :name, FALSE
+                )
+                ON CONFLICT (conversation_id, participant_provider_id, participant_deleted_at)
+                DO UPDATE SET participant_name = EXCLUDED.participant_name"
+            );
+            $stmt->execute([
+                'conversation_id' => $conversationId,
+                'provider_id' => $providerId,
+                'name' => mb_substr($this->value($attendee, ['attendee_name', 'name'], $providerId), 0, 100),
+            ]);
+        }
+    }
+
+    private function replaceAttachments(int $messageId, array $attachments): void
+    {
+        foreach ($attachments as $attachment) {
+            if (!is_array($attachment)) {
+                continue;
+            }
+
+            $externalId = trim((string) ($attachment['id'] ?? ''));
+            if ($externalId === '') {
+                continue;
+            }
+
+            $stmt = $this->pdo->prepare(
+                "INSERT INTO message_attachment (
+                    message_id, attachment_external_id, attachment_url, attachment_type,
+                    attachment_mime_type, attachment_file_name, attachment_size
+                ) VALUES (
+                    :message_id, :external_id, :url, :type,
+                    :mime_type, :file_name, :size
+                )
+                ON CONFLICT (message_id, attachment_external_id, attachment_deleted_at)
+                DO UPDATE SET
+                    attachment_url = EXCLUDED.attachment_url,
+                    attachment_type = EXCLUDED.attachment_type,
+                    attachment_mime_type = EXCLUDED.attachment_mime_type,
+                    attachment_file_name = EXCLUDED.attachment_file_name,
+                    attachment_size = EXCLUDED.attachment_size"
+            );
+            $stmt->execute([
+                'message_id' => $messageId,
+                'external_id' => $externalId,
+                'url' => (string) ($attachment['url'] ?? ''),
+                'type' => mb_substr((string) ($attachment['type'] ?? ''), 0, 50),
+                'mime_type' => mb_substr((string) ($attachment['mimetype'] ?? $attachment['mime_type'] ?? ''), 0, 100),
+                'file_name' => mb_substr((string) ($attachment['file_name'] ?? $externalId), 0, 255),
+                'size' => $this->attachmentSize($attachment),
+            ]);
+        }
+    }
+
+    private function resolveOperatorId(int $companyId, int $preferredOperatorId): int
+    {
+        if ($preferredOperatorId > 0) {
+            $stmt = $this->pdo->prepare(
+                "SELECT operator_id
+                FROM operator
+                WHERE company_id = :company_id
+                  AND operator_id = :operator_id
+                  AND operator_deleted_at = 'infinity'
+                LIMIT 1"
+            );
+            $stmt->execute([
+                'company_id' => $companyId,
+                'operator_id' => $preferredOperatorId,
+            ]);
+            if ($stmt->fetchColumn() !== false) {
+                return $preferredOperatorId;
+            }
+        }
+
+        $stmt = $this->pdo->prepare(
+            "SELECT operator_id
+            FROM operator
+            WHERE company_id = :company_id
+              AND operator_deleted_at = 'infinity'
+            ORDER BY operator_id ASC
+            LIMIT 1"
+        );
+        $stmt->execute(['company_id' => $companyId]);
+        $operatorId = $stmt->fetchColumn();
+
+        return $operatorId === false ? 0 : (int) $operatorId;
+    }
+
+    private function isOperatorMessage(array $integration, array $payload, string $senderProviderId): bool
+    {
+        if (isset($payload['is_sender'])) {
+            return (bool) $payload['is_sender'];
+        }
+
+        $accountInfo = is_array($payload['account_info'] ?? null) ? $payload['account_info'] : [];
+        $accountUserId = (string) ($accountInfo['user_id'] ?? $integration['integration_external_account_id'] ?? '');
+
+        return $accountUserId !== '' && $senderProviderId !== '' && $accountUserId === $senderProviderId;
+    }
+
+    private function normalizePhone(string $providerId): string
+    {
+        $digits = preg_replace('/\D+/', '', $providerId) ?? '';
+        if ($digits !== '') {
+            return mb_substr($digits, 0, 20);
+        }
+
+        return mb_substr((string) abs(crc32($providerId)), 0, 20);
+    }
+
+    private function normalizeTimestamp(string $timestamp): string
+    {
+        $time = strtotime($timestamp);
+        if ($time === false) {
+            $time = time();
+        }
+
+        return gmdate('Y-m-d H:i:s', $time);
+    }
+
+    private function resolveClientIdentity(array $integration, array $payload, array $sender, array $attendees, string $senderProviderId, bool $isOperator): array
+    {
+        $senderName = $this->value($sender, ['attendee_name', 'name'], $senderProviderId);
+        if (!$isOperator) {
+            return [$senderProviderId, $senderName];
+        }
+
+        $accountInfo = is_array($payload['account_info'] ?? null) ? $payload['account_info'] : [];
+        $accountUserId = (string) ($accountInfo['user_id'] ?? $integration['integration_external_account_id'] ?? '');
+
+        foreach ($attendees as $attendee) {
+            if (!is_array($attendee)) {
+                continue;
+            }
+
+            $providerId = $this->value($attendee, ['attendee_provider_id', 'id'], '');
+            if ($providerId === '' || $providerId === $accountUserId) {
+                continue;
+            }
+
+            return [
+                $providerId,
+                $this->value($attendee, ['attendee_name', 'name'], $providerId),
+            ];
+        }
+
+        return [$senderProviderId, $senderName];
+    }
+
+    private function attachmentSize(array $attachment): int
+    {
+        $size = $attachment['size'] ?? 0;
+        if (is_array($size)) {
+            return (int) ($size['bytes'] ?? $size['file_size'] ?? 0);
+        }
+
+        if (is_numeric($size)) {
+            return (int) $size;
+        }
+
+        return 0;
+    }
+
+    private function value(array $data, array $keys, string $default): string
+    {
+        foreach ($keys as $key) {
+            if (isset($data[$key]) && trim((string) $data[$key]) !== '') {
+                return trim((string) $data[$key]);
+            }
+        }
+
+        return $default;
+    }
+}

+ 8 - 0
routes/Dispatcher.php

@@ -27,6 +27,10 @@ final class Dispatcher
         // ---- Público (sem autenticação) -------------------------------------
         // Login é protegido contra brute-force via RateLimiter no controller.
         $app->post('/v1/login', \Controllers\LoginController::class);
+        $app->post('/v1/webhooks/unipile', \Controllers\UnipileWebhookController::class);
+        $app->post('/v1/webhooks/unipile/hosted-auth', \Controllers\UnipileHostedAuthWebhookController::class);
+        $app->get('/v1/integrations/unipile/whatsapp/success', new \Controllers\UnipileRedirectController('success'));
+        $app->get('/v1/integrations/unipile/whatsapp/failure', new \Controllers\UnipileRedirectController('failure'));
 
         // ---- Somente admin --------------------------------------------------
         // Cadastro de usuários: o novo usuário herda o company_id do solicitante.
@@ -51,6 +55,7 @@ final class Dispatcher
         $app->get('/v1/dashboard/overview', $auth, \Controllers\DashboardOverviewController::class);
         $app->get('/v1/interactions', $auth, \Controllers\InteractionsController::class);
         $app->get('/v1/interactions/details', $auth, \Controllers\InteractionDetailsController::class);
+        $app->post('/v1/interactions/messages', $auth, new RoleMiddleware(Roles::ADMIN, Roles::MANAGER, Roles::OPERATOR), \Controllers\InteractionSendMessageController::class);
         $app->get('/v1/analytics/sentiment/dashboard', $auth, \Controllers\AnalyticsSentimentDashboardController::class);
         $app->get('/v1/personas/overview', $auth, \Controllers\PersonasOverviewController::class);
         $app->get('/v1/evolution/overview', $auth, \Controllers\EvolutionOverviewController::class);
@@ -66,5 +71,8 @@ final class Dispatcher
         $app->post('/v1/agents', $auth, \Controllers\AgentSaveController::class);
         $app->post('/v1/agents/status', $auth, \Controllers\AgentStatusController::class);
         $app->post('/v1/agents/escalation', $auth, \Controllers\AgentEscalationController::class);
+
+        $app->get('/v1/integrations/unipile/whatsapp/accounts', $auth, new RoleMiddleware(Roles::ADMIN, Roles::MANAGER, Roles::OPERATOR), \Controllers\UnipileAccountsController::class);
+        $app->post('/v1/integrations/unipile/whatsapp/hosted-link', $auth, new RoleMiddleware(Roles::ADMIN, Roles::MANAGER, Roles::OPERATOR), \Controllers\UnipileHostedLinkController::class);
     }
 }

+ 112 - 0
services/UnipileClient.php

@@ -0,0 +1,112 @@
+<?php
+
+namespace Services;
+
+use Libs\Logger;
+
+class UnipileClient
+{
+    private string $dsn;
+    private string $apiKey;
+
+    public function __construct()
+    {
+        $this->dsn = trim((string) ($_ENV['UNIPILE_DSN'] ?? ''));
+        $this->apiKey = trim((string) ($_ENV['UNIPILE_API_KEY'] ?? ''));
+    }
+
+    public function isConfigured(): bool
+    {
+        return $this->dsn !== '' && $this->apiKey !== '';
+    }
+
+    public function createHostedAuthLink(array $payload): array
+    {
+        return $this->requestJson('POST', '/hosted/accounts/link', $payload);
+    }
+
+    public function sendTextMessage(string $chatId, string $text): array
+    {
+        return $this->requestMultipart('POST', '/chats/' . rawurlencode($chatId) . '/messages', ['text' => $text]);
+    }
+
+    public function apiUrl(): string
+    {
+        return $this->baseEndpoint();
+    }
+
+    private function requestJson(string $method, string $path, array $payload = []): array
+    {
+        return $this->request($method, $path, [
+            'Content-Type: application/json',
+        ], $payload === [] ? '' : json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
+    }
+
+    private function requestMultipart(string $method, string $path, array $payload): array
+    {
+        return $this->request($method, $path, [], $payload);
+    }
+
+    private function request(string $method, string $path, array $headers, $body): array
+    {
+        if (!$this->isConfigured()) {
+            throw new \RuntimeException('Unipile is not configured.');
+        }
+
+        if (!function_exists('curl_init')) {
+            throw new \RuntimeException('PHP cURL extension is required for Unipile requests.');
+        }
+
+        $url = $this->baseUrl() . $path;
+        $requestHeaders = array_merge([
+            'X-API-KEY: ' . $this->apiKey,
+            'Accept: application/json',
+        ], $headers);
+
+        $ch = curl_init($url);
+        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+        curl_setopt($ch, CURLOPT_CUSTOMREQUEST, strtoupper($method));
+        curl_setopt($ch, CURLOPT_HTTPHEADER, $requestHeaders);
+        curl_setopt($ch, CURLOPT_TIMEOUT, 30);
+
+        if ($body !== '' && $body !== []) {
+            curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
+        }
+
+        $raw = curl_exec($ch);
+        $error = curl_error($ch);
+        $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
+        curl_close($ch);
+
+        if ($raw === false) {
+            Logger::error('Unipile request failed', ['path' => $path, 'error' => $error]);
+            throw new \RuntimeException('Unipile request failed.');
+        }
+
+        $decoded = json_decode((string) $raw, true);
+        if (!is_array($decoded)) {
+            $decoded = ['raw' => (string) $raw];
+        }
+
+        if ($status < 200 || $status >= 300) {
+            Logger::warning('Unipile returned an error', ['path' => $path, 'status' => $status, 'response' => $decoded]);
+            throw new \RuntimeException('Unipile returned HTTP ' . $status . '.');
+        }
+
+        return $decoded;
+    }
+
+    private function baseUrl(): string
+    {
+        return $this->baseEndpoint() . '/api/v1';
+    }
+
+    private function baseEndpoint(): string
+    {
+        $dsn = preg_replace('#^https?://#', '', $this->dsn) ?? $this->dsn;
+        $dsn = rtrim($dsn, '/');
+        $dsn = preg_replace('#/api/v1$#', '', $dsn) ?? $dsn;
+
+        return 'https://' . $dsn;
+    }
+}