diff --git a/.env.example b/.env.example index 5cc6cd5..dc1c493 100644 --- a/.env.example +++ b/.env.example @@ -80,29 +80,35 @@ NGINX_SSL_PATH=./docker/nginx/ssl/ MAPA_URL= -JWT_SECRET= - #### RABBITMQ #### +RABBITMQ_ENABLED=true + RABBITMQ_DEFAULT_HOST=ip_ou_host RABBITMQ_DEFAULT_PORT=5672 RABBITMQ_DEFAULT_USER=user RABBITMQ_DEFAULT_PASS=pass -RABBITMQ_ENABLED=true -RABBITMQ_EXCHANGE_DEFAULT=RABBITMQ_EXCHANGE_DEFAULT -RABBITMQ_QUEUE_PUBLISHED_RECOURSES=RABBITMQ_QUEUE_PUBLISHED_RECOURSES -QUEUE_OPINION_MANAGEMENT=QUEUE_OPINION_MANAGEMENT -QUEUE_PUBLISHED_RECOURSES=QUEUE_PUBLISHED_RECOURSES -QUEUE_IMPORT_REGISTRATION=QUEUE_IMPORT_REGISTRATION -MODULE_IMPORT_REGISTRATION_DRAFT=MODULE_IMPORT_REGISTRATION_DRAFT -QUEUE_ACCOUNTABILITY=QUEUE_ACCOUNTABILITY -MODULE_ACCOUNTABILITY_ADM=MODULE_ACCOUNTABILITY_ADM -MODULE_ACCOUNTABILITY_PROPONENT=MODULE_ACCOUNTABILITY_PROPONENT -PLUGIN_PUBLISHED_RECOURSES=PLUGIN_PUBLISHED_RECOURSES +RABBITMQ_VHOST=/ + +RABBITMQ_EXCHANGE_DEFAULT=exchange_notification + +RABBITMQ_QUEUE_PUBLISHED_RECOURSES=rabbitmq_queue_published_recourses +QUEUE_OPINION_MANAGEMENT=queue_opinion_management +QUEUE_PUBLISHED_RECOURSES=queue_published_recourses +QUEUE_IMPORT_REGISTRATION=queue_import_registration +MODULE_IMPORT_REGISTRATION_DRAFT=module_import_registration_draft +QUEUE_ACCOUNTABILITY=queue_accountability +MODULE_ACCOUNTABILITY_ADM=module_accountability_adm +MODULE_ACCOUNTABILITY_PROPONENT=module_accountability_proponent +PLUGIN_PUBLISHED_RECOURSES=plugin_published_recourses + +RABBITMQ_IMPORT_REGISTRATION_RETRY_ENABLED=true + +RABBITMQ_PREFETCH_COUNT=1 ## EMAIL FORWARD_MAILPIT_DASHBOARD_PORT=8026 + ## SENTRY SENTRY_LARAVEL_DSN=https://link_do_sentry SENTRY_SEND_DEFAULT_PII=true SENTRY_TRACES_SAMPLE_RATE=1.0 - diff --git a/README.md b/README.md index e8b5002..f1ac91d 100644 --- a/README.md +++ b/README.md @@ -1,65 +1,183 @@ -

MC Ceará Logo

- +# API de E-mail — Mapa Cultural do Ceará ## Sobre -Esse repositório é uma API REST e será utilizado para disparo de e-mail de agentes culturais do Mapa Cultural do Ceará. O mesmo terá conexão com o serviço do [Rabbitmq](https://github.com/rabbitmq) que servirá como a mensageria e fila para todas as requisições de um produtor para essa API que será o consumidor +Este repositório contém uma API REST em Laravel utilizada para envio de e-mails a agentes culturais do Mapa Cultural do Ceará. A aplicação se conecta ao RabbitMQ, responsável pela mensageria entre os sistemas produtores e os consumidores desta API. + +Além dos demais fluxos da aplicação, o consumidor de **importação de inscrições** processa notificações de inscrições importadas e controla a idempotência por `opportunity_id` e `registration_id`, para evitar novos envios em caso de mensagens duplicadas. -## Outros Repositórios +## Outros repositórios - [Mapa Cultural do Ceará V5](https://laravel.com/docs/routing). -- [Mpa Cultural do Ceará V7](https://laravel.com/docs/container). +- [Mapa Cultural do Ceará V7](https://laravel.com/docs/container). + +## Laravel Framework -## Laravel Framewwork +O Laravel é o framework PHP utilizado pela aplicação. Consulte a [documentação oficial](https://laravel.com/docs/contributions). -Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling - [Laravel](https://laravel.com). +## Instalação -### Outros Links +Instruções existentes para subir a API: -- **[Laravel documentation](https://laravel.com/docs/contributions).** +```bash +cp .env.example .env +docker compose up -d +docker exec php_api composer update +docker exec php_api php artisan key:generate +docker exec php_api php artisan migrate +docker exec php_api chmod -R 777 database +``` -## Contribuição +A aplicação fica disponível na porta **8081**, conforme a configuração atual do projeto. -Sempre é bem vinda! +## RabbitMQ -## License +As instruções gerais de conexão estão em [docs/RABBITMQ.md](https://github.com/secultce/api-email/blob/develop/docs/RABBITMQ.md). -The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT). +### Notificação de inscrições importadas +O fluxo de importação utiliza a fila principal `queue_import_registration` e a exchange `exchange_notification`, com routing key `module_import_registration_draft`. -## Instalação +O consumidor Laravel executa o processamento e utiliza a chave única `(opportunity_id, registration_id)` para evitar o envio repetido de notificações da mesma inscrição. A DLQ definitiva `queue_import_registration_dlq` recebe mensagens rejeitadas que não devem ser reprocessadas automaticamente. + +### Retry controlado + +O retry é permitido **somente quando a falha é comprovadamente anterior ao início de `Mail::send()`**: + +- Falha na reserva da inscrição, antes do início do envio. +- Falha de preparação, renderização ou auditoria do e-mail, desde que o estado `failed` tenha sido persistido com sucesso. + +A partir da tentativa de envio SMTP, o resultado pode ser incerto. Nesse caso, o consumidor registra `unknown` e **não reenvia automaticamente**. Os estados `sending`, `unknown` e `sent` também não são elegíveis a reenvio automático. Se houver falha ao persistir `failed`, a mensagem segue para tratamento de falha/DLQ, em vez de autorizar um retry potencialmente duplicado. + +#### Intervalos e limite de tentativas + +| Processamento | Cabeçalho `x-import-registration-retry-count` | Destino após uma falha segura | +| --- | ---: | --- | +| Inicial | 0 ou ausente | Retry 1, após 1 minuto | +| Retry 1 | 1 | Retry 2, após 5 minutos | +| Retry 2 | 2 | Retry 3, após 15 minutos | +| Retry 3 | 3 | DLQ definitiva | + +São **até quatro processamentos** na mesma cadeia (o inicial e três retries). O contador é transportado em um **cabeçalho AMQP**; o JSON da mensagem, inclusive `message_id`, não é alterado. + +O campo `attempts` do banco contabiliza reservas de processamento e não necessariamente coincide com a quantidade de entregas ou com o cabeçalho AMQP, especialmente quando uma falha ocorre antes da reserva. + +#### Encaminhamento seguro para retry + +Quando identifica uma falha elegível, o consumidor publica a mensagem na exchange `exchange_notification_retry`, na rota correspondente ao próximo retry. A publicação utiliza mensagem persistente, `mandatory=true` e **publisher confirms**; o `ack()` da mensagem original ocorre somente após confirmar a publicação e verificar que ela não foi devolvida por ausência de roteamento. + +Caso a publicação falhe ou seu resultado seja incerto, o consumidor não confirma a mensagem original; o RabbitMQ poderá reentregá-la quando a conexão de consumo se encerrar. A publicação de retry e o `ack()` **não são atômicos**: uma falha entre essas operações pode produzir duplicatas, bloqueadas pela idempotência da aplicação. + +#### Configuração + +Adicione ao `config/rabbitmq.php`, **preservando as configurações já usadas pelos demais módulos**: + +```php +'retry' => [ + 'enabled' => env('RABBITMQ_IMPORT_REGISTRATION_RETRY_ENABLED', false), + 'exchange' => 'exchange_notification_retry', + 'routes' => [ + 1 => 'module_import_registration_draft_retry_1', + 2 => 'module_import_registration_draft_retry_2', + 3 => 'module_import_registration_draft_retry_3', + ], +], +``` + +Inicie com o recurso desligado no `.env`: -Instruções para instalação da api com os seguintes comandos: +```dotenv +RABBITMQ_IMPORT_REGISTRATION_RETRY_ENABLED=false +``` -- `cp .env.example .env` -- `docker compose up -d` -- `docker exec php_api composer update` -- `docker exec php_api php artisan key:generate` -- `docker exec php_api php artisan migrate` -- `docker exec php_api chmod -R 777 database` +Após implantar e validar as configurações no RabbitMQ, incluindo as filas, bindings e permissões do usuário RabbitMQ do Laravel, habilite: -Acessar na porta 8081 +```dotenv +RABBITMQ_IMPORT_REGISTRATION_RETRY_ENABLED=true +``` -## Conexão para RabbitMQ +Recarregue a configuração e reinicie o worker para aplicar a alteração: -Instruções [aqui](https://github.com/secultce/api-email/blob/develop/docs/RABBITMQ.md) +```bash +php artisan optimize:clear +# Se o ambiente utiliza cache de configuração: +php artisan config:cache +# Reinicie o processo correspondente na configuração do Supervisor. +``` -## Conexão para as tarefas agendadas +Desabilitar a flag interrompe **novos encaminhamentos** para as filas de retry, mas não cancela mensagens que já estejam aguardando TTL; elas continuarão retornando à fila principal. -Instruções [aqui](https://github.com/secultce/api-email/blob/develop/docs/TASKSHEDULING.md) +#### Implantação +1. Confirme a existência da exchange `exchange_notification_retry`, das três filas de retry, das respectivas rotas e da DLQ definitiva. +2. Verifique se o usuário usado pela API tem permissão de publicação na nova exchange e acesso à fila principal. +3. Execute os testes automatizados e os testes integrados em homologação, sem disparar e-mails reais. +4. Habilite a flag, atualize o cache de configuração, reinicie o worker e monitore as filas e os registros de processamento. -### JWT Auth +Não é necessário recriar filas existentes nem apagar volumes do RabbitMQ. -É usado o pacote jwt-auth (https://jwt-auth.readthedocs.io/en/develop/) para criar tokens de acesso que são enviados nas requisições, para garantir que pessoas não autorizadas acessem os dados retornados nos endpoints usados na api. +#### Testes automatizados + +Use um banco **exclusivo de testes**, compatível com as migrations existentes: + +```bash +php artisan test tests/Feature/ImportRegistrationProcessorTest.php +php artisan test tests/Feature/ImportRegistrationCommandTest.php +php artisan test tests/Unit/ImportRegistrationRetryPublisherTest.php +``` + +Execute também o teste de concorrência do fluxo de importação: + +```bash +RUN_IMPORT_REGISTRATION_CONCURRENCY_TEST=1 \ +php artisan test tests/Integration/ImportRegistrationConcurrencyTest.php +``` + +**Confirme que o teste de concorrência foi executado e aprovado**, e não simplesmente ignorado por falta de configuração. + +#### Cenários de homologação + +- Falha anterior ao SMTP: mensagem encaminhada ao retry 1; após TTL, retorna à fila principal. +- Falhas seguras recorrentes: percorrem os retries 1, 2 e 3; após o limite, a mensagem vai para a DLQ. +- E-mail enviado: estado `sent`, com `ack()` e sem novo envio. +- Resultado SMTP incerto: estado `unknown`, sem retry automático. +- Mensagem inválida ou cabeçalho de retry incorreto: rejeição para a DLQ. +- Falha na publicação do retry: mensagem original permanece sem ACK. +- Redelivery de mensagem já processada: nenhuma duplicação de e-mail. + +Simule falhas em ambiente controlado e com destinatários de teste. Não utilize destinatários reais para provocar erros de SMTP. + +#### Monitoramento e limitações + +Monitore a fila principal, as três filas de retry, a DLQ e os registros com status `failed`, `sending` e `unknown`. + +O mecanismo não garante entrega exatamente uma vez, não recupera automaticamente resultados SMTP incertos e não resolve falhas de publicação no produtor Mapas Culturais. Não reenvie mensagens da DLQ nem altere indiscriminadamente estados `sending` ou `unknown` para `failed`. + +## Tarefas agendadas + +As instruções de configuração estão em [docs/TASKSHEDULING.md](https://github.com/secultce/api-email/blob/develop/docs/TASKSHEDULING.md). + +## Autenticação JWT + +A API utiliza o pacote [jwt-auth](https://jwt-auth.readthedocs.io/en/develop/) para gerar tokens de acesso utilizados na autenticação de requisições a endpoints protegidos. + +## Workers e Supervisor + +Para cada componente que processe tarefas de fila, adicione o comando correspondente ao arquivo `supervisord.conf`, localizado em `docker/php-fpm/`, seguindo o padrão atual do projeto: + +```ini +[program:name_command] +command=php artisan command:command +autostart=true +autorestart=true +stderr_logfile=/dev/stderr +stdout_logfile=/dev/stdout +``` + +## Contribuição -### Workers +Contribuições são bem-vindas. -Para cada novo componente que precise trabalhar com as tarefas de filas por exemplo, deve ser adicionar o comando no arquivo *supervisord.conf* , que se encontra no caminho ***docker/php-fpm/*** e escrever algo como: +## Licença - [program:name_command] - command=php artisan command:command - autostart=true - autorestart=true - stderr_logfile=/dev/stderr - stdout_logfile=/dev/stdout \ No newline at end of file +O projeto utiliza o Laravel, framework de código aberto disponibilizado sob a [licença MIT](https://opensource.org/licenses/MIT). Confirme a licença específica desta aplicação nos arquivos do repositório. diff --git a/app/Console/Commands/ImportRegistrationCommand.php b/app/Console/Commands/ImportRegistrationCommand.php index 94c1ffc..23e6d8b 100644 --- a/app/Console/Commands/ImportRegistrationCommand.php +++ b/app/Console/Commands/ImportRegistrationCommand.php @@ -2,97 +2,179 @@ namespace App\Console\Commands; -use App\Events\ImportRegistrationEvent; -use Illuminate\Console\Command; +use App\Services\EmailDeliveryOutcomeUnknownException; +use App\Services\ImportRegistrationProcessor; +use App\Services\ImportRegistrationRetryPublisher; +use App\Services\InvalidRegistrationMessageException; use App\Services\RabbitMQService; +use App\Services\RetryableImportRegistrationException; +use Illuminate\Console\Command; use Illuminate\Support\Facades\Log; use PhpAmqpLib\Message\AMQPMessage; +use PhpAmqpLib\Wire\AMQPTable; +use Throwable; class ImportRegistrationCommand extends Command { - /** - * The name and signature of the console command. - * - * @var string - */ protected $signature = 'rabbitmq:import-registration-command'; - /** - * The console command description. - * - * @var string - */ - protected $description = 'Comando para consumir a fila de importação de inscrições'; + protected $description = 'Consome mensagens de importação de inscrições'; - protected $queueService; - - public function __construct(RabbitMQService $queueService) - { + public function __construct( + protected RabbitMQService $queueService, + protected ImportRegistrationProcessor $processor, + protected ImportRegistrationRetryPublisher $retryPublisher + ) { parent::__construct(); - - $this->queueService = $queueService; } - /** - * Executa o comando no console. - */ - public function handle() + public function handle(): int { + if (!config('rabbitmq.enabled', true)) { + Log::info('Consumidor de inscrições desabilitado pela configuração.'); + + return self::SUCCESS; + } + $queue = config('rabbitmq.queues.queue_import_registration'); $exchange = config('rabbitmq.exchange_default'); $routingKey = config('rabbitmq.routing.module_import_registration_draft'); - $this->queueService->consume( - $queue, - $exchange, - $routingKey, - function (AMQPMessage $msg) { - $this->processMessage($msg); - } - ); + try { + $this->queueService->consume( + $queue, + $exchange, + $routingKey, + function (AMQPMessage $msg): void { + $this->processMessage($msg); + } + ); + + return self::SUCCESS; + } catch (Throwable $e) { + Log::critical('O consumidor RabbitMQ foi encerrado por uma falha.', [ + 'exception' => $e->getMessage(), + 'queue' => $queue, + ]); + + report($e); + + return self::FAILURE; + } } - protected function processMessage(AMQPMessage $msg) + protected function processMessage(AMQPMessage $msg): void { - Log::info('Mensagem recebida: ' . $msg->getBody()); + $body = $msg->getBody(); + $context = ['body_sha256' => hash('sha256', $body)]; + $retryCount = 0; try { - $registration = json_decode($msg->getBody(), true); - - // Verifica se a mensagem contém uma única inscrição. - if ( - !is_array($registration) || - !array_key_exists('registration', $registration) - ) { - Log::error( - 'Formato de mensagem inválido. Esperada uma única inscrição.', - [ - 'body' => $msg->getBody(), - ] - ); + // Lê o contador antes de efeitos colaterais; cabeçalho malformado é inválido. + $retryCount = $this->readRetryCount($msg); + + try { + $data = json_decode($body, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new InvalidRegistrationMessageException('O corpo da mensagem não contém um JSON válido.', 0, $e); + } + + if (!is_array($data)) { + throw new InvalidRegistrationMessageException('A mensagem deve conter um objeto JSON.'); + } + + $context += [ + 'message_id' => $data['message_id'] ?? null, + 'opportunity_id' => $data['opportunity_id'] ?? null, + 'registration_id' => $data['registration'] ?? null, + 'retry_count' => $retryCount, + ]; + + $this->processor->process($data); + } catch (InvalidRegistrationMessageException $e) { + Log::error('Mensagem de inscrição inválida.', $context + ['exception' => $e->getMessage()]); + + $msg->nack(false, false); // DLQ definitiva - // Descarta a mensagem inválida. - $msg->ack(); + return; + } catch (EmailDeliveryOutcomeUnknownException $e) { + Log::critical('O resultado do envio do e-mail é desconhecido.', $context + [ + 'exception' => $e->getMessage(), + ]); + + report($e); + // O status unknown foi persistido; o ACK fica após os catches. + } catch (RetryableImportRegistrationException $e) { + $routes = config('rabbitmq.retry.routes', []); + $nextRetry = $retryCount + 1; + + if (!config('rabbitmq.retry.enabled', false) || !isset($routes[$nextRetry])) { + Log::error('Tentativas de retry desabilitadas ou esgotadas; enviando à DLQ.', $context + [ + 'exception' => $e->getMessage(), + 'retry_count' => $retryCount, + ]); + + $msg->nack(false, false); return; } - event(new ImportRegistrationEvent($registration)); + try { + $this->retryPublisher->publish($body, $nextRetry); + } catch (Throwable $publicationException) { + Log::critical( + 'Falha ao publicar na fila de retry; mensagem original permanece sem ACK.', + $context + ['exception' => $publicationException->getMessage()] + ); - // Confirma a mensagem após o processamento. - $msg->ack(); + report($publicationException); - Log::info( - 'Mensagem processada com sucesso para a inscrição: ' - . $registration['registration'] - ); - } catch (\Throwable $e) { - \Sentry\captureException($e); + // Propaga para o loop de consumo encerrar a conexão. + // O RabbitMQ poderá reentregar a mensagem original. + throw $publicationException; + } + + Log::warning('Mensagem encaminhada para retry após falha anterior ao SMTP.', $context + [ + 'next_retry' => $nextRetry, + ]); - Log::error('Erro ao processar a mensagem de importação de inscrição.', [ + $msg->ack(); // ACK somente depois do publisher confirm positivo. + + return; + } catch (Throwable $e) { + Log::error('Falha não recuperável automaticamente.', $context + [ 'exception' => $e->getMessage(), - 'body' => $msg->getBody(), ]); + + report($e); + + $msg->nack(false, false); // DLQ; não reenviar SMTP incerto. + + return; + } + + $msg->ack(); + } + + private function readRetryCount(AMQPMessage $msg): int + { + if (!$msg->has('application_headers')) { + return 0; } + + $headers = $msg->get('application_headers'); + if (!$headers instanceof AMQPTable) { + throw new InvalidRegistrationMessageException('Cabeçalho AMQP inválido.'); + } + + $data = $headers->getNativeData(); + $count = $data['x-import-registration-retry-count'] ?? 0; + $max = count(config('rabbitmq.retry.routes', [])); + + if (!is_int($count) || $count < 0 || $count > $max) { + throw new InvalidRegistrationMessageException('Contador de retry inválido.'); + } + + return $count; } } diff --git a/app/Events/ImportRegistrationEvent.php b/app/Events/ImportRegistrationEvent.php deleted file mode 100644 index 894bc08..0000000 --- a/app/Events/ImportRegistrationEvent.php +++ /dev/null @@ -1,40 +0,0 @@ -registration = $registration; - } - - /** - * Retorna os canais em que o evento seria transmitido. - * - * @return array - */ - public function broadcastOn(): array - { - return [ - new PrivateChannel('channel-name'), - ]; - } -} diff --git a/app/Listeners/ImportRegistrationListener.php b/app/Listeners/ImportRegistrationListener.php deleted file mode 100644 index 4a65f13..0000000 --- a/app/Listeners/ImportRegistrationListener.php +++ /dev/null @@ -1,85 +0,0 @@ -registration; - - // Valida os dados da inscrição. - if (!$this->isValidRegistration($registration)) { - Log::error( - 'Chaves obrigatórias ausentes na inscrição: ' - . json_encode($registration) - ); - - return; - } - - Log::info( - 'Enviando e-mail para: ' . $registration['agent_email'] - ); - - // Cria o Mailable para a inscrição. - $mailable = new ImporteRegistrationMail($registration); - - // Registra a auditoria do e-mail em EmailDispatch. - EmailDispatch::create([ - 'to' => $registration['agent_name'], - 'subject' => $mailable->envelope()->subject, - 'content' => $mailable->render(), - 'mailable_type' => ImporteRegistrationMail::class, - 'meta' => [ - 'registration' => $registration['registration'], - 'opp_id' => $registration['opp_id'], - 'opp_name' => $registration['opp_name'], - 'number' => $registration['number'], - 'agent_name' => $registration['agent_name'], - 'agent_email' => $registration['agent_email'], - ], - 'dispatched_at' => now(), - ]); - - // Envia o e-mail para o responsável pela inscrição. - Mail::to($registration['agent_email'])->send($mailable); - - Log::info( - 'E-mail enviado e auditado para: ' - . $registration['agent_email'] - ); - } - - /** - * Valida se a inscrição possui todas as chaves obrigatórias. - */ - protected function isValidRegistration(array $registration): bool - { - return isset( - $registration['registration'], - $registration['opp_id'], - $registration['opp_name'], - $registration['number'], - $registration['agent_name'], - $registration['agent_email'] - ); - } -} diff --git a/app/Mail/ImporteRegistrationMail.php b/app/Mail/ImporteRegistrationMail.php index db25211..25d81d3 100644 --- a/app/Mail/ImporteRegistrationMail.php +++ b/app/Mail/ImporteRegistrationMail.php @@ -3,7 +3,6 @@ namespace App\Mail; use Illuminate\Bus\Queueable; -use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Envelope; @@ -40,8 +39,8 @@ public function content(): Content view: 'emails.import-registration', with: [ 'registration' => $this->data['registration'], - 'opp_id' => $this->data['opp_id'], - 'opp_name' => $this->data['opp_name'], + 'opportunity_id' => $this->data['opportunity_id'], + 'opportunity_name' => $this->data['opportunity_name'], 'number' => $this->data['number'], 'agent_name' => $this->data['agent_name'], 'agent_email' => $this->data['agent_email'], diff --git a/app/Models/EmailDispatch.php b/app/Models/EmailDispatch.php index e1ac183..cb5c823 100644 --- a/app/Models/EmailDispatch.php +++ b/app/Models/EmailDispatch.php @@ -23,6 +23,7 @@ class EmailDispatch extends Model implements Auditable 'subject', 'meta' ]; + /** * The attributes that are mass assignable. * @@ -34,7 +35,11 @@ class EmailDispatch extends Model implements Auditable 'content', 'mailable_type', 'meta', + 'status', + 'attempted_at', 'dispatched_at', + 'failed_at', + 'error_message', ]; /** @@ -43,8 +48,9 @@ class EmailDispatch extends Model implements Auditable * @var array */ protected $casts = [ - 'meta' => 'array' + 'meta' => 'array', + 'attempted_at' => 'datetime', + 'dispatched_at' => 'datetime', + 'failed_at' => 'datetime', ]; - - } diff --git a/app/Models/ImportRegistrationEmailProcess.php b/app/Models/ImportRegistrationEmailProcess.php new file mode 100644 index 0000000..43d0aa2 --- /dev/null +++ b/app/Models/ImportRegistrationEmailProcess.php @@ -0,0 +1,33 @@ + 'integer', + 'started_at' => 'datetime', + 'sent_at' => 'datetime', + 'failed_at' => 'datetime', + ]; + } +} diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index a4a3691..e6ee748 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -3,10 +3,7 @@ namespace App\Providers; use App\Events\OpinionManagementEvent; -use App\Events\ImportRegistrationEvent; -use App\Listeners\AuditMessageListener; use App\Listeners\OpinionManagementListener; -use App\Listeners\ImportRegistrationListener; use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider; class EventServiceProvider extends ServiceProvider @@ -15,9 +12,6 @@ class EventServiceProvider extends ServiceProvider OpinionManagementEvent::class => [ OpinionManagementListener::class, ], - ImportRegistrationEvent::class => [ - ImportRegistrationListener::class, - ], ]; /** * Register services. diff --git a/app/Services/EmailDeliveryOutcomeUnknownException.php b/app/Services/EmailDeliveryOutcomeUnknownException.php new file mode 100644 index 0000000..078c582 --- /dev/null +++ b/app/Services/EmailDeliveryOutcomeUnknownException.php @@ -0,0 +1,7 @@ + 'opp_id', 'opportunity_name' => 'opp_name'] as $field => $legacyField) { + if (!array_key_exists($field, $data) && array_key_exists($legacyField, $data)) { + $data[$field] = $data[$legacyField]; + } + } + + $validator = Validator::make($data, [ + 'event_type' => [ + 'sometimes', + 'string', + 'in:registration.import_requested', + ], + 'schema_version' => [ + 'sometimes', + 'integer', + 'in:1', + ], + 'occurred_at' => [ + 'sometimes', + 'date', + ], + 'message_id' => [ + 'sometimes', + 'nullable', + 'uuid', + ], + 'registration' => [ + 'required', + 'integer', + 'min:1', + ], + 'opportunity_id' => [ + 'required', + 'integer', + 'min:1', + ], + 'opportunity_name' => [ + 'required', + 'string', + ], + 'number' => [ + 'required', + ], + 'agent_name' => [ + 'required', + 'string', + ], + 'agent_email' => [ + 'required', + 'email', + ], + ]); + + if ($validator->fails()) { + throw new InvalidRegistrationMessageException($validator->errors()->toJson()); + } + + $messageId = trim((string) ($data['message_id'] ?? '')); + + if ($messageId === '') { + $messageId = (string) Str::uuid(); + } + + /* + * Reserva a combinação oportunidade/inscrição no banco. + * + * Registros sent, sending e unknown não podem iniciar + * outro envio automaticamente. + * + * Registros pending e failed podem ser processados. + */ + try { + $process = $this->reserveProcess($data, $messageId); + } catch (Throwable $e) { + // Nenhum Mail::send() foi chamado neste processamento. + throw new RetryableImportRegistrationException('Falha antes do início do envio: não foi possível reservar a inscrição.', 0, $e); + } + + if (!$process) { + Log::warning('Mensagem duplicada ou já processada.', [ + 'opportunity_id' => $data['opportunity_id'], + 'registration' => $data['registration'], + 'message_id' => $messageId, + ]); + + return; + } + + $dispatch = null; + + /* + * Prepara e registra a auditoria antes de tentar enviar. + * + * Uma falha nesta etapa é considerada segura para tentar + * novamente, porque Mail::send() ainda não foi chamado. + */ + try { + $mailable = new ImporteRegistrationMail($data); + + $subject = $mailable->envelope()->subject; + + $content = $mailable->render(); + + $dispatch = EmailDispatch::create([ + 'to' => $data['agent_email'], + 'subject' => $subject, + 'content' => $content, + 'mailable_type' => ImporteRegistrationMail::class, + 'meta' => [ + 'registration' => $data['registration'], + 'opportunity_id' => $data['opportunity_id'], + 'opportunity_name' => $data['opportunity_name'], + 'number' => $data['number'], + 'agent_name' => $data['agent_name'], + 'agent_email' => $data['agent_email'], + 'message_id' => $messageId, + ], + 'status' => 'pending', + 'attempted_at' => null, + 'dispatched_at' => null, + 'failed_at' => null, + 'error_message' => null, + ]); + + /* + * Marca o início da tentativa imediatamente antes + * da chamada ao serviço de e-mail. + */ + $dispatch->status = 'sending'; + $dispatch->attempted_at = now(); + $dispatch->save(); + } catch (Throwable $e) { + // Se falhar a persistência de failed, a exceção original de banco + // será propagada e enviada à DLQ, sem programar novo envio. + $this->markFailed($process, $dispatch, $e); + + throw new RetryableImportRegistrationException('Falha antes do início do envio SMTP.', 0, $e); + } + + /* + * Tenta enviar o e-mail e registrar o resultado. + * + * A partir deste ponto, uma exceção pode representar + * um envio que foi aceito pelo SMTP sem confirmação + * recebida pela aplicação. + */ + try { + Mail::to($data['agent_email'])->send($mailable); + + DB::transaction(function () use ($process, $dispatch) { + $processRecord = ImportRegistrationEmailProcess::query() + ->whereKey($process->id) + ->lockForUpdate() + ->firstOrFail(); + + $processRecord->status = 'sent'; + $processRecord->sent_at = now(); + $processRecord->failed_at = null; + $processRecord->last_error = null; + $processRecord->save(); + + $dispatchRecord = EmailDispatch::query() + ->whereKey($dispatch->id) + ->lockForUpdate() + ->firstOrFail(); + + $dispatchRecord->status = 'sent'; + $dispatchRecord->dispatched_at = now(); + $dispatchRecord->failed_at = null; + $dispatchRecord->error_message = null; + $dispatchRecord->save(); + }); + + Log::info('E-mail aceito pelo serviço de envio.', [ + 'opportunity_id' => $data['opportunity_id'], + 'registration' => $data['registration'], + 'email_dispatch_id' => $dispatch->id, + 'message_id' => $messageId, + ]); + } catch (Throwable $e) { + $this->markUnknown($process, $dispatch, $e); + + throw new EmailDeliveryOutcomeUnknownException('O envio terminou com resultado desconhecido.', 0, $e); + } + } + + /** + * Reserva o processamento de maneira segura para concorrência. + * + * A restrição UNIQUE no banco é indispensável. + */ + private function reserveProcess(array $data, string $messageId): ?ImportRegistrationEmailProcess + { + return DB::transaction(function () use ($data, $messageId) { + $now = now(); + + /* + * Insere um registro inicial se a combinação ainda não + * existir. Se outro consumidor já criou o registro, + * a restrição UNIQUE impede uma segunda inserção. + */ + DB::table('import_registration_email_processes') + ->insertOrIgnore([ + 'opportunity_id' => $data['opportunity_id'], + 'registration_id' => $data['registration'], + 'status' => 'pending', + 'attempts' => 0, + 'message_id' => $messageId, + 'agent_email' => $data['agent_email'], + 'started_at' => null, + 'sent_at' => null, + 'failed_at' => null, + 'last_error' => null, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + /* + * Bloqueia a linha para serializar as alterações + * de estado da mesma inscrição. + */ + $process = ImportRegistrationEmailProcess::query() + ->where('opportunity_id', $data['opportunity_id']) + ->where('registration_id', $data['registration']) + ->lockForUpdate() + ->first(); + + if (!$process) { + throw new RuntimeException('Não foi possível reservar o processamento da inscrição.'); + } + + /* + * Nunca reenviar automaticamente quando o resultado + * anterior foi enviado, está em andamento ou é incerto. + */ + if (!in_array($process->status, ['pending', 'failed'], true)) { + return null; + } + + $process->status = 'sending'; + $process->attempts = ((int) $process->attempts) + 1; + $process->message_id = $messageId; + $process->agent_email = $data['agent_email']; + $process->started_at = now(); + $process->sent_at = null; + $process->failed_at = null; + $process->last_error = null; + $process->save(); + + return $process; + }); + } + + /** + * Registra uma falha ocorrida antes da tentativa de envio. + * + * Como o envio ainda não começou, o processamento poderá + * ser reexecutado posteriormente. + */ + private function markFailed(ImportRegistrationEmailProcess $process, ?EmailDispatch $dispatch, Throwable $exception): void + { + try { + DB::transaction(function () use ($process, $dispatch, $exception) { + $processRecord = ImportRegistrationEmailProcess::query() + ->whereKey($process->id) + ->lockForUpdate() + ->first(); + + if ($processRecord) { + $processRecord->status = 'failed'; + $processRecord->failed_at = now(); + $processRecord->last_error = Str::limit($exception->getMessage(), 2000); + $processRecord->save(); + } + + if ($dispatch) { + $dispatchRecord = EmailDispatch::query() + ->whereKey($dispatch->id) + ->lockForUpdate() + ->first(); + + if ($dispatchRecord) { + $dispatchRecord->status = 'failed'; + $dispatchRecord->failed_at = now(); + $dispatchRecord->error_message = Str::limit($exception->getMessage(), 2000); + $dispatchRecord->save(); + } + } + }); + } catch (Throwable $databaseException) { + Log::critical( + 'Não foi possível registrar a falha anterior ao envio.', + [ + 'process_id' => $process->id, + 'dispatch_id' => $dispatch?->id, + 'exception' => $exception->getMessage(), + 'database_exception' => $databaseException->getMessage(), + ] + ); + + // Sem status failed persistido, um retry pode encontrar sending + // e descartar silenciosamente a notificação. Exigir investigação. + throw $databaseException; + } + } + + /** + * Registra um resultado de envio que não pode ser confirmado. + * + * Não autoriza um novo envio automático. + */ + private function markUnknown(ImportRegistrationEmailProcess $process, EmailDispatch $dispatch, Throwable $exception): void + { + try { + DB::transaction(function () use ($process, $dispatch, $exception) { + $processRecord = ImportRegistrationEmailProcess::query() + ->whereKey($process->id) + ->lockForUpdate() + ->firstOrFail(); + + $processRecord->status = 'unknown'; + $processRecord->last_error = Str::limit($exception->getMessage(), 2000); + $processRecord->save(); + + $dispatchRecord = EmailDispatch::query() + ->whereKey($dispatch->id) + ->lockForUpdate() + ->firstOrFail(); + + $dispatchRecord->status = 'unknown'; + $dispatchRecord->error_message = Str::limit($exception->getMessage(), 2000); + $dispatchRecord->save(); + }); + } catch (Throwable $databaseException) { + Log::critical('Não foi possível registrar o resultado desconhecido do envio.', [ + 'process_id' => $process->id, + 'dispatch_id' => $dispatch->id, + 'exception' => $exception->getMessage(), + 'database_exception' => $databaseException->getMessage(), + ]); + + // A falha de persistencia precisa chegar ao consumidor. + // Ele fara NACK sem requeue, preservando a mensagem na DLQ. + // O estado 'sending' continua bloqueando um segundo envio. + throw $databaseException; + } + } +} diff --git a/app/Services/ImportRegistrationRetryPublisher.php b/app/Services/ImportRegistrationRetryPublisher.php new file mode 100644 index 0000000..d93daea --- /dev/null +++ b/app/Services/ImportRegistrationRetryPublisher.php @@ -0,0 +1,101 @@ +openConnection(); + $channel = $connection->channel(); + + $channel->set_ack_handler(function (AMQPMessage $message) use (&$confirmed): void { + $confirmed = true; + }); + + $channel->set_nack_handler(function (AMQPMessage $message) use (&$nacked): void { + $nacked = true; + }); + + $channel->set_return_listener(function ( + $replyCode, + $replyText, + $returnedExchange, + $returnedRoutingKey, + AMQPMessage $message + ) use (&$returned): void { + $returned = true; + }); + + $channel->confirm_select(); + + $retryMessage = new AMQPMessage($body, [ + 'content_type' => 'application/json', + 'delivery_mode' => AMQPMessage::DELIVERY_MODE_PERSISTENT, + 'application_headers' => new AMQPTable([ + 'x-import-registration-retry-count' => $retryNumber, + ]), + ]); + + // mandatory = true: se não houver binding/rota, o broker retorna a mensagem. + $channel->basic_publish($retryMessage, $exchange, $routingKey, true); + $channel->wait_for_pending_acks_returns(10); + + if (!$confirmed || $nacked || $returned) { + throw new RuntimeException('RabbitMQ não confirmou o roteamento da mensagem para a fila de retry.'); + } + } finally { + // Não mascarar falha de publicação por erro de fechamento de conexão. + if ($channel !== null) { + try { + $channel->close(); + } catch (Throwable $ignored) { + } + } + if ($connection !== null) { + try { + $connection->close(); + } catch (Throwable $ignored) { + } + } + } + } + + /** Substituível nos testes; nenhuma conexão é aberta pelo construtor. */ + protected function openConnection(): AMQPStreamConnection + { + return new AMQPStreamConnection( + config('rabbitmq.host'), + (int) config('rabbitmq.port'), + config('rabbitmq.user'), + config('rabbitmq.pass'), + config('rabbitmq.vhost', '/') + ); + } +} diff --git a/app/Services/InvalidRegistrationMessageException.php b/app/Services/InvalidRegistrationMessageException.php new file mode 100644 index 0000000..1f52f4d --- /dev/null +++ b/app/Services/InvalidRegistrationMessageException.php @@ -0,0 +1,7 @@ +connect(); - } + private $channel = null; - private function connect(): bool + private function connect(): void { - // Evitar conexão em desenvolvimento se RabbitMQ não estiver configurado - if (env('APP_ENV') === 'local' && !env('RABBITMQ_ENABLED', false)) { - Log::info('RabbitMQ connection skipped in local environment'); - return false; - } - try { - $this->connection = new AMQPStreamConnection( - config('rabbitmq.host'), - config('rabbitmq.port'), - config('rabbitmq.user'), - config('rabbitmq.pass'), - config('rabbitmq.vhost') - ); + $this->connection = $this->openConnection(); $this->channel = $this->connection->channel(); - Log::info('Conexão com RabbitMQ estabelecida com sucesso'); - return true; - } catch (\Exception $e) { - Log::error('Erro ao conectar ao RabbitMQ: ' . $e->getMessage(), [ - 'host' => config('rabbitmq.host', 'localhost'), - 'port' => config('rabbitmq.port', 5672), - 'user' => config('rabbitmq.user', 'guest'), - 'vhost' => config('rabbitmq.vhost', '/') + + Log::info('Conexão com RabbitMQ estabelecida com sucesso.'); + } catch (Throwable $e) { + Log::critical('Não foi possível estabelecer conexão com RabbitMQ.', [ + 'host' => config('rabbitmq.host'), + 'port' => config('rabbitmq.port'), + 'vhost' => config('rabbitmq.vhost'), + 'exception' => $e->getMessage(), ]); - $this->connection = null; - $this->channel = null; - return false; + + $this->close(); + + /* + * Não deixar a aplicação prosseguir como se + * o consumidor estivesse funcionando. + */ + throw new RuntimeException('Falha ao conectar ao RabbitMQ.', 0, $e); } } + protected function openConnection(): AMQPStreamConnection + { + return new AMQPStreamConnection( + config('rabbitmq.host'), + config('rabbitmq.port'), + config('rabbitmq.user'), + config('rabbitmq.pass'), + config('rabbitmq.vhost') + ); + } + public function consume(string $queue, string $exchange, string $routingKey, callable $callback): void { - if (!$this->channel) { - Log::error('Não foi possível consumir mensagens: canal do RabbitMQ não inicializado', [ - 'queue' => $queue, - 'exchange' => $exchange, - 'routingKey' => $routingKey - ]); + if (!config('rabbitmq.enabled', true)) { + Log::info('Consumo RabbitMQ ignorado: integração desabilitada.'); + return; } + + $this->connect(); + try { - // Declarar a exchange + /* + * A exchange precisa ter o mesmo tipo e as mesmas + * propriedades utilizadas pelo produtor. + */ $this->channel->exchange_declare($exchange, 'direct', false, true, false); - // Declarar a fila + /* + * A fila é durável. + * + * Configure a DLX por meio de uma policy no RabbitMQ, + * conforme a seção de configuração abaixo. + */ $this->channel->queue_declare($queue, false, true, false, false); - // Vincular a fila à exchange $this->channel->queue_bind($queue, $exchange, $routingKey); - // Consumir mensagens - $this->channel->basic_consume($queue, '', false, false, false, false, function ($msg) use ($callback) { - $callback($msg); - }); + $prefetchCount = max(1, (int) config('rabbitmq.prefetch_count', 1)); + + $this->channel->basic_qos(0, $prefetchCount, false); + + $this->channel->basic_consume( + $queue, + '', + false, + false, + false, + false, + function (AMQPMessage $msg) use ($callback): void { + $callback($msg); + } + ); + + Log::info('Consumidor RabbitMQ iniciado.', [ + 'queue' => $queue, + 'exchange' => $exchange, + 'routing_key' => $routingKey, + 'prefetch_count' => $prefetchCount, + ]); - Log::info("Consumindo mensagens da fila: {$queue}"); while ($this->channel->is_consuming()) { $this->channel->wait(); } - } catch (\Exception $e) { - Log::error('Erro ao consumir mensagem do RabbitMQ: ' . $e->getMessage(), [ + } catch (Throwable $e) { + Log::critical('Falha no consumidor RabbitMQ.', [ 'queue' => $queue, 'exchange' => $exchange, - 'routingKey' => $routingKey + 'routing_key' => $routingKey, + 'exception' => $e->getMessage(), ]); + + throw $e; + } finally { + $this->close(); } } - public function __destruct() + private function close(): void { try { if ($this->channel) { $this->channel->close(); } + } catch (Throwable $e) { + Log::warning('Erro ao fechar o canal RabbitMQ.', [ + 'exception' => $e->getMessage() + ]); + } + + try { if ($this->connection) { $this->connection->close(); } - } catch (\Exception $e) { - Log::error('Erro ao fechar conexão com RabbitMQ: ' . $e->getMessage()); + } catch (Throwable $e) { + Log::warning('Erro ao fechar a conexão RabbitMQ.', [ + 'exception' => $e->getMessage() + ]); } + + $this->channel = null; + $this->connection = null; + } + + public function __destruct() + { + $this->close(); } } diff --git a/app/Services/RetryableImportRegistrationException.php b/app/Services/RetryableImportRegistrationException.php new file mode 100644 index 0000000..8d81661 --- /dev/null +++ b/app/Services/RetryableImportRegistrationException.php @@ -0,0 +1,11 @@ +=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Time\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Philippe Nyamagana Butera", + "email": "nyamsprod@gmail.com" + }, + { + "name": "Tim Düsterhus", + "email": "tim@tideways-gmbh.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Time API added to PHP 8.6", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "duration", + "polyfill", + "portable", + "shim", + "time" + ], + "support": { + "source": "https://github.com/symfony/polyfill-time/tree/v1.42.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-07T13:08:13+00:00" + }, { "name": "symfony/polyfill-uuid", "version": "v1.43.0", @@ -6147,16 +6228,16 @@ }, { "name": "symfony/process", - "version": "v7.4.21", + "version": "v7.4.22", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "e5247f4a5e0797d232ad8e8e6803aeb341843c83" + "reference": "a5a6912edbbfc4c98cd080bc4bbe37c8e7eeb20a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/e5247f4a5e0797d232ad8e8e6803aeb341843c83", - "reference": "e5247f4a5e0797d232ad8e8e6803aeb341843c83", + "url": "https://api.github.com/repos/symfony/process/zipball/a5a6912edbbfc4c98cd080bc4bbe37c8e7eeb20a", + "reference": "a5a6912edbbfc4c98cd080bc4bbe37c8e7eeb20a", "shasum": "" }, "require": { @@ -6188,7 +6269,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.4.21" + "source": "https://github.com/symfony/process/tree/v7.4.22" }, "funding": [ { @@ -6208,7 +6289,7 @@ "type": "tidelift" } ], - "time": "2026-10-08T16:56:12+00:00" + "time": "2026-10-25T14:40:16+00:00" }, { "name": "symfony/psr-http-message-bridge", @@ -6299,16 +6380,16 @@ }, { "name": "symfony/routing", - "version": "v7.4.21", + "version": "v7.4.23", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "7a344cf871532d081875bfdd18f3dff8dca36c5b" + "reference": "0000ade604b7bc798cb1e75edf6f17df48185f73" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/7a344cf871532d081875bfdd18f3dff8dca36c5b", - "reference": "7a344cf871532d081875bfdd18f3dff8dca36c5b", + "url": "https://api.github.com/repos/symfony/routing/zipball/0000ade604b7bc798cb1e75edf6f17df48185f73", + "reference": "0000ade604b7bc798cb1e75edf6f17df48185f73", "shasum": "" }, "require": { @@ -6360,7 +6441,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.4.21" + "source": "https://github.com/symfony/routing/tree/v7.4.23" }, "funding": [ { @@ -6380,20 +6461,20 @@ "type": "tidelift" } ], - "time": "2026-10-11T18:40:59+00:00" + "time": "2026-11-04T08:27:22+00:00" }, { "name": "symfony/service-contracts", - "version": "v3.7.3", + "version": "v3.8.1", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257" + "reference": "052852a96fd5e5ebdb617dba22031e41a3300fb5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", - "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/052852a96fd5e5ebdb617dba22031e41a3300fb5", + "reference": "052852a96fd5e5ebdb617dba22031e41a3300fb5", "shasum": "" }, "require": { @@ -6411,7 +6492,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.7-dev" + "dev-main": "3.8-dev" } }, "autoload": { @@ -6447,7 +6528,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.7.3" + "source": "https://github.com/symfony/service-contracts/tree/v3.8.1" }, "funding": [ { @@ -6467,20 +6548,20 @@ "type": "tidelift" } ], - "time": "2026-07-27T15:39:01+00:00" + "time": "2026-10-20T17:38:25+00:00" }, { "name": "symfony/string", - "version": "v8.1.9", + "version": "v8.1.10", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "07fb208558f941eab50ec49c89c2a6a93fb11f01" + "reference": "6c6110979c0150393bbb336170e286d51ab4d76c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/07fb208558f941eab50ec49c89c2a6a93fb11f01", - "reference": "07fb208558f941eab50ec49c89c2a6a93fb11f01", + "url": "https://api.github.com/repos/symfony/string/zipball/6c6110979c0150393bbb336170e286d51ab4d76c", + "reference": "6c6110979c0150393bbb336170e286d51ab4d76c", "shasum": "" }, "require": { @@ -6537,7 +6618,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.1.9" + "source": "https://github.com/symfony/string/tree/v8.1.10" }, "funding": [ { @@ -6557,20 +6638,20 @@ "type": "tidelift" } ], - "time": "2026-10-03T15:16:03+00:00" + "time": "2026-10-23T17:06:53+00:00" }, { "name": "symfony/translation", - "version": "v8.1.5", + "version": "v8.1.11", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "d9e1caba0d6b6f9a26710af8a2f88d37f001215a" + "reference": "d065adf56e5508293e80d7c140b0a9e043b63e9c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/d9e1caba0d6b6f9a26710af8a2f88d37f001215a", - "reference": "d9e1caba0d6b6f9a26710af8a2f88d37f001215a", + "url": "https://api.github.com/repos/symfony/translation/zipball/d065adf56e5508293e80d7c140b0a9e043b63e9c", + "reference": "d065adf56e5508293e80d7c140b0a9e043b63e9c", "shasum": "" }, "require": { @@ -6630,7 +6711,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v8.1.5" + "source": "https://github.com/symfony/translation/tree/v8.1.11" }, "funding": [ { @@ -6650,20 +6731,20 @@ "type": "tidelift" } ], - "time": "2026-08-21T17:47:34+00:00" + "time": "2026-11-01T22:27:46+00:00" }, { "name": "symfony/translation-contracts", - "version": "v3.7.1", + "version": "v3.8.1", "source": { "type": "git", "url": "https://github.com/symfony/translation-contracts.git", - "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621" + "reference": "0a3ae917402632fcba2aeddae9c98332f3d7dd05" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/ccb206b98faccc511ebae8e5fad50f2dc0b30621", - "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/0a3ae917402632fcba2aeddae9c98332f3d7dd05", + "reference": "0a3ae917402632fcba2aeddae9c98332f3d7dd05", "shasum": "" }, "require": { @@ -6676,7 +6757,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.7-dev" + "dev-main": "3.8-dev" } }, "autoload": { @@ -6712,7 +6793,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/translation-contracts/tree/v3.7.1" + "source": "https://github.com/symfony/translation-contracts/tree/v3.8.1" }, "funding": [ { @@ -6732,7 +6813,7 @@ "type": "tidelift" } ], - "time": "2026-06-05T06:23:12+00:00" + "time": "2026-10-03T08:17:19+00:00" }, { "name": "symfony/uid", @@ -6814,16 +6895,16 @@ }, { "name": "symfony/var-dumper", - "version": "v7.4.21", + "version": "v7.4.22", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "55d06b02f49eca9bff61828548988e6f8763e8a6" + "reference": "6e6ff33ea8cf5df27f00a5561ebd5ee9bd7f1618" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/55d06b02f49eca9bff61828548988e6f8763e8a6", - "reference": "55d06b02f49eca9bff61828548988e6f8763e8a6", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/6e6ff33ea8cf5df27f00a5561ebd5ee9bd7f1618", + "reference": "6e6ff33ea8cf5df27f00a5561ebd5ee9bd7f1618", "shasum": "" }, "require": { @@ -6877,7 +6958,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.4.21" + "source": "https://github.com/symfony/var-dumper/tree/v7.4.22" }, "funding": [ { @@ -6897,7 +6978,7 @@ "type": "tidelift" } ], - "time": "2026-10-13T05:48:33+00:00" + "time": "2026-10-25T21:04:04+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -6956,16 +7037,16 @@ }, { "name": "tymon/jwt-auth", - "version": "2.3.0", + "version": "2.3.1", "source": { "type": "git", "url": "https://github.com/tymondesigns/jwt-auth.git", - "reference": "6c70930a92710d97e8e52b182fca2176097f33be" + "reference": "99e8f24cf96bfb6d847597c6a80713879a0a19c1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/tymondesigns/jwt-auth/zipball/6c70930a92710d97e8e52b182fca2176097f33be", - "reference": "6c70930a92710d97e8e52b182fca2176097f33be", + "url": "https://api.github.com/repos/tymondesigns/jwt-auth/zipball/99e8f24cf96bfb6d847597c6a80713879a0a19c1", + "reference": "99e8f24cf96bfb6d847597c6a80713879a0a19c1", "shasum": "" }, "require": { @@ -7030,20 +7111,20 @@ "issues": "https://github.com/tymondesigns/jwt-auth/issues", "source": "https://github.com/tymondesigns/jwt-auth" }, - "time": "2026-03-06T09:50:45+00:00" + "time": "2026-10-24T15:03:54+00:00" }, { "name": "vlucas/phpdotenv", - "version": "v5.7.0", + "version": "v5.8.0", "source": { "type": "git", "url": "https://github.com/phpdotenv/phpdotenv.git", - "reference": "301c07936b16d88628b126b01d082ba153cf4c40" + "reference": "96149103e62ac67e915f6d2ef59b72dee2e011e0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpdotenv/phpdotenv/zipball/301c07936b16d88628b126b01d082ba153cf4c40", - "reference": "301c07936b16d88628b126b01d082ba153cf4c40", + "url": "https://api.github.com/repos/phpdotenv/phpdotenv/zipball/96149103e62ac67e915f6d2ef59b72dee2e011e0", + "reference": "96149103e62ac67e915f6d2ef59b72dee2e011e0", "shasum": "" }, "require": { @@ -7070,7 +7151,7 @@ "forward-command": false }, "branch-alias": { - "dev-master": "5.6-dev" + "dev-master": "5.8-dev" } }, "autoload": { @@ -7101,8 +7182,8 @@ "environment" ], "support": { - "issues": "https://github.com/vlucas/phpdotenv/issues", - "source": "https://github.com/vlucas/phpdotenv/tree/v5.7.0" + "issues": "https://github.com/phpdotenv/phpdotenv/issues", + "source": "https://github.com/phpdotenv/phpdotenv/tree/v5.8.0" }, "funding": [ { @@ -7114,7 +7195,7 @@ "type": "tidelift" } ], - "time": "2026-08-24T18:07:49+00:00" + "time": "2026-10-25T16:26:19+00:00" }, { "name": "voku/portable-ascii", @@ -7412,16 +7493,16 @@ }, { "name": "filp/whoops", - "version": "2.18.6", + "version": "2.19.2", "source": { "type": "git", "url": "https://github.com/filp/whoops.git", - "reference": "7d0a2cde892089bb09372f4366e9b960eb3001a9" + "reference": "84e33631273138ac103d045dc02b0fb7e5485582" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filp/whoops/zipball/7d0a2cde892089bb09372f4366e9b960eb3001a9", - "reference": "7d0a2cde892089bb09372f4366e9b960eb3001a9", + "url": "https://api.github.com/repos/filp/whoops/zipball/84e33631273138ac103d045dc02b0fb7e5485582", + "reference": "84e33631273138ac103d045dc02b0fb7e5485582", "shasum": "" }, "require": { @@ -7471,7 +7552,7 @@ ], "support": { "issues": "https://github.com/filp/whoops/issues", - "source": "https://github.com/filp/whoops/tree/2.18.6" + "source": "https://github.com/filp/whoops/tree/2.19.2" }, "funding": [ { @@ -7479,7 +7560,7 @@ "type": "github" } ], - "time": "2026-10-03T17:03:57+00:00" + "time": "2026-11-03T23:38:01+00:00" }, { "name": "hamcrest/hamcrest-php", @@ -9712,16 +9793,16 @@ }, { "name": "spatie/flare-client-php", - "version": "1.11.1", + "version": "1.11.2", "source": { "type": "git", "url": "https://github.com/spatie/flare-client-php.git", - "reference": "53f41b08a27cc039e1a8ed2be9a202e924f31bad" + "reference": "181cab18224b7ed3ec66bccdf645c0e04a1aa3b1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/flare-client-php/zipball/53f41b08a27cc039e1a8ed2be9a202e924f31bad", - "reference": "53f41b08a27cc039e1a8ed2be9a202e924f31bad", + "url": "https://api.github.com/repos/spatie/flare-client-php/zipball/181cab18224b7ed3ec66bccdf645c0e04a1aa3b1", + "reference": "181cab18224b7ed3ec66bccdf645c0e04a1aa3b1", "shasum": "" }, "require": { @@ -9769,7 +9850,7 @@ ], "support": { "issues": "https://github.com/spatie/flare-client-php/issues", - "source": "https://github.com/spatie/flare-client-php/tree/1.11.1" + "source": "https://github.com/spatie/flare-client-php/tree/1.11.2" }, "funding": [ { @@ -9777,7 +9858,7 @@ "type": "github" } ], - "time": "2026-05-15T09:31:32+00:00" + "time": "2026-11-04T15:14:45+00:00" }, { "name": "spatie/ignition", @@ -9867,16 +9948,16 @@ }, { "name": "spatie/laravel-ignition", - "version": "2.12.0", + "version": "2.12.1", "source": { "type": "git", "url": "https://github.com/spatie/laravel-ignition.git", - "reference": "45b3b6e1e73fc161cba2149972698644b99594ee" + "reference": "0d49228686542bf718ad1a978ab3eb1d5a0dcd41" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/laravel-ignition/zipball/45b3b6e1e73fc161cba2149972698644b99594ee", - "reference": "45b3b6e1e73fc161cba2149972698644b99594ee", + "url": "https://api.github.com/repos/spatie/laravel-ignition/zipball/0d49228686542bf718ad1a978ab3eb1d5a0dcd41", + "reference": "0d49228686542bf718ad1a978ab3eb1d5a0dcd41", "shasum": "" }, "require": { @@ -9955,7 +10036,7 @@ "type": "github" } ], - "time": "2026-03-17T12:20:04+00:00" + "time": "2026-10-19T16:31:25+00:00" }, { "name": "staabm/side-effects-detector", @@ -10011,16 +10092,16 @@ }, { "name": "symfony/yaml", - "version": "v8.1.9", + "version": "v8.1.11", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "6107ee26a25215c3df3af0a3315e56bb4fd0b7db" + "reference": "e8266f55160f674f290a5f567cb55e11e21f921b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/6107ee26a25215c3df3af0a3315e56bb4fd0b7db", - "reference": "6107ee26a25215c3df3af0a3315e56bb4fd0b7db", + "url": "https://api.github.com/repos/symfony/yaml/zipball/e8266f55160f674f290a5f567cb55e11e21f921b", + "reference": "e8266f55160f674f290a5f567cb55e11e21f921b", "shasum": "" }, "require": { @@ -10063,7 +10144,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v8.1.9" + "source": "https://github.com/symfony/yaml/tree/v8.1.11" }, "funding": [ { @@ -10083,7 +10164,7 @@ "type": "tidelift" } ], - "time": "2026-10-12T12:37:13+00:00" + "time": "2026-11-04T08:39:20+00:00" }, { "name": "theseer/tokenizer", diff --git a/config/rabbitmq.php b/config/rabbitmq.php index e2c5796..f1c78c3 100644 --- a/config/rabbitmq.php +++ b/config/rabbitmq.php @@ -1,21 +1,42 @@ env('RABBITMQ_ENABLED', true), + 'host' => env('RABBITMQ_DEFAULT_HOST', 'localhost'), - 'port' => env('RABBITMQ_DEFAULT_PORT', 5672), + 'port' => (int) env('RABBITMQ_DEFAULT_PORT', 5672), 'user' => env('RABBITMQ_DEFAULT_USER', 'guest'), 'pass' => env('RABBITMQ_DEFAULT_PASS', 'guest'), 'vhost' => env('RABBITMQ_VHOST', '/'), + 'exchange_default' => env('RABBITMQ_EXCHANGE_DEFAULT', 'exchange_notification'), + 'queues' => [ 'queue_opinion_management' => env('QUEUE_OPINION_MANAGEMENT', 'queue_opinion_management'), 'queue_import_registration' => env('QUEUE_IMPORT_REGISTRATION', 'queue_import_registration'), 'queue_published_recourses' => env('QUEUE_PUBLISHED_RECOURSES', 'queue_published_recourses'), 'queue_accountability' => env('QUEUE_ACCOUNTABILITY', 'queue_accountability'), ], + 'routing' => [ 'module_import_registration_draft' => env('MODULE_IMPORT_REGISTRATION_DRAFT', 'module_import_registration_draft'), 'module_accountability_proponent' => env('MODULE_ACCOUNTABILITY_PROPONENT', 'module_accountability_proponent'), 'module_accountability_adm' => env('MODULE_ACCOUNTABILITY_ADM', 'module_accountability_adm'), 'plugin_published_recourses' => env('PLUGIN_PUBLISHED_RECOURSES', 'plugin_published_recourses'), ], + + 'prefetch_count' => (int) env('RABBITMQ_PREFETCH_COUNT', 1), + + 'retry' => [ + // Ativar somente depois de importar as definições no broker. + 'enabled' => env('RABBITMQ_IMPORT_REGISTRATION_RETRY_ENABLED', false), + 'exchange' => 'exchange_notification_retry', + // O índice representa o número de retries JÁ realizados (não a tentativa total). + // Os TTLs são fixados no definitions.json: 60s, 300s e 900s. + 'routes' => [ + 1 => 'module_import_registration_draft_retry_1', + 2 => 'module_import_registration_draft_retry_2', + 3 => 'module_import_registration_draft_retry_3', + ], + ], ]; diff --git a/database/migrations/2026_10_21_131926_create_import_registration_email_processes_table.php b/database/migrations/2026_10_21_131926_create_import_registration_email_processes_table.php new file mode 100644 index 0000000..cc432a3 --- /dev/null +++ b/database/migrations/2026_10_21_131926_create_import_registration_email_processes_table.php @@ -0,0 +1,43 @@ +id(); + + $table->unsignedBigInteger('opportunity_id'); + $table->unsignedBigInteger('registration_id'); + + $table->string('status', 30)->default('pending'); + $table->unsignedInteger('attempts')->default(0); + $table->uuid('message_id')->nullable(); + $table->string('agent_email'); + $table->timestamp('started_at')->nullable(); + $table->timestamp('sent_at')->nullable(); + $table->timestamp('failed_at')->nullable(); + $table->text('last_error')->nullable(); + + $table->timestamps(); + + $table->unique( + ['opportunity_id', 'registration_id'], + 'uq_import_registration_opportunity' + ); + + $table->index(['status', 'updated_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists( + 'import_registration_email_processes' + ); + } +}; diff --git a/database/migrations/2026_10_21_142031_add_delivery_status_to_email_dispatches_table.php b/database/migrations/2026_10_21_142031_add_delivery_status_to_email_dispatches_table.php new file mode 100644 index 0000000..3082bf2 --- /dev/null +++ b/database/migrations/2026_10_21_142031_add_delivery_status_to_email_dispatches_table.php @@ -0,0 +1,30 @@ +string('status', 30)->default('pending'); + $table->timestamp('attempted_at')->nullable(); + $table->timestamp('failed_at')->nullable(); + $table->text('error_message')->nullable(); + }); + } + + public function down(): void + { + Schema::table('email_dispatches', function (Blueprint $table) { + $table->dropColumn([ + 'status', + 'attempted_at', + 'failed_at', + 'error_message', + ]); + }); + } +}; diff --git a/database/migrations/2026_10_21_144106_make_dispatched_at_nullable_on_email_dispatches.php b/database/migrations/2026_10_21_144106_make_dispatched_at_nullable_on_email_dispatches.php new file mode 100644 index 0000000..f1d917c --- /dev/null +++ b/database/migrations/2026_10_21_144106_make_dispatched_at_nullable_on_email_dispatches.php @@ -0,0 +1,29 @@ +timestamp('dispatched_at') + ->nullable() + ->change(); + }); + } + + public function down(): void + { + /* + * Não restauramos a obrigatoriedade automaticamente: + * registros criados após a migration podem ter NULL. + * + * Antes de reverter, seria necessário definir uma + * estratégia para esses registros. + */ + throw new RuntimeException('Reversão manual necessária para dispatched_at.'); + } +}; diff --git a/docker/production/supervisor/conf.d/import_registration.conf b/docker/production/supervisor/conf.d/import_registration.conf index dec814a..ecbb731 100644 --- a/docker/production/supervisor/conf.d/import_registration.conf +++ b/docker/production/supervisor/conf.d/import_registration.conf @@ -1,8 +1,23 @@ [program:import_registration] process_name=%(program_name)s_%(process_num)02d + +directory=/var/www/html + command=php artisan rabbitmq:import-registration-command + autostart=true autorestart=true + +startsecs=5 +startretries=10 + numprocs=1 + +stopwaitsecs=60 +stopsignal=TERM + redirect_stderr=true -stdout_logfile=/var/log/supervisor/import_registration.log \ No newline at end of file + +stdout_logfile=/var/log/supervisor/import_registration.log +stdout_logfile_maxbytes=20MB +stdout_logfile_backups=10 diff --git a/resources/views/emails/import-registration.blade.php b/resources/views/emails/import-registration.blade.php index d98e859..8ddddcb 100644 --- a/resources/views/emails/import-registration.blade.php +++ b/resources/views/emails/import-registration.blade.php @@ -2,7 +2,7 @@ -