From 53f29ddc8f0b2ae64d8872b4b66e536743867f41 Mon Sep 17 00:00:00 2001 From: Victor Ferreira Date: Wed, 4 Feb 2026 14:46:47 -0300 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20implementa=20exporta=C3=A7=C3=A3o?= =?UTF-8?q?=20ass=C3=ADncrona=20de=20mapas=20poligonais?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 4 + config/packages/messenger.yaml | 18 +++ docker-compose.yml | 16 +++ .../NotificationDocumentService.php | 9 ++ src/Regmel/Command/CleanOldExportsCommand.php | 63 ++++++++++ .../Web/Admin/ExportsAdminController.php | 54 +++++++++ .../Web/Admin/ProposalAdminController.php | 35 ++++-- .../Message/GenerateMapFilesZipMessage.php | 13 +++ .../GenerateMapFilesZipMessageHandler.php | 110 ++++++++++++++++++ .../Interface/ProposalServiceInterface.php | 2 + src/Regmel/Service/ProposalService.php | 61 ++++++++++ 11 files changed, 374 insertions(+), 11 deletions(-) create mode 100644 config/packages/messenger.yaml create mode 100644 src/Regmel/Command/CleanOldExportsCommand.php create mode 100644 src/Regmel/Controller/Web/Admin/ExportsAdminController.php create mode 100644 src/Regmel/Message/GenerateMapFilesZipMessage.php create mode 100644 src/Regmel/MessageHandler/GenerateMapFilesZipMessageHandler.php diff --git a/.env b/.env index a1865fa3c..8a65ae149 100644 --- a/.env +++ b/.env @@ -48,6 +48,10 @@ EMAIL_ADDRESS="no-reply@regmel.com" CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$' ###< nelmio/cors-bundle ### +###> symfony/messenger ### +MESSENGER_TRANSPORT_DSN=doctrine://default?queue_name=async +###< symfony/messenger ### + ###> symfony/mailer ### MAILER_DSN=null://null ###< symfony/mailer ### diff --git a/config/packages/messenger.yaml b/config/packages/messenger.yaml new file mode 100644 index 000000000..2507c32b2 --- /dev/null +++ b/config/packages/messenger.yaml @@ -0,0 +1,18 @@ +framework: + messenger: + failure_transport: failed + + transports: + async: + dsn: '%env(MESSENGER_TRANSPORT_DSN)%' + options: + queue_name: async + retry_strategy: + max_retries: 3 + multiplier: 2 + + failed: + dsn: 'doctrine://default?queue_name=failed' + + routing: + 'App\Regmel\Message\GenerateMapFilesZipMessage': async diff --git a/docker-compose.yml b/docker-compose.yml index d3ece13ae..b377feb44 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -93,6 +93,22 @@ services: networks: - regmel_network + messenger-worker: + container_name: regmel-messenger-worker + build: + context: ./ + dockerfile: ./docker/php/Dockerfile + restart: unless-stopped + command: php bin/console messenger:consume async --time-limit=3600 --memory-limit=512M --limit=10 + volumes: + - ./:/var/www + - ./docker/php/local.ini:/usr/local/etc/php/conf.d/local.ini + depends_on: + - postgres + - mongo + networks: + - regmel_network + networks: regmel_network: driver: bridge diff --git a/src/DocumentService/NotificationDocumentService.php b/src/DocumentService/NotificationDocumentService.php index 1d5994286..5cc33d546 100644 --- a/src/DocumentService/NotificationDocumentService.php +++ b/src/DocumentService/NotificationDocumentService.php @@ -87,4 +87,13 @@ public function markAllAsVisitedByTarget(string $targetUserId): void $this->documentManager->flush(); } + + /** + * Cria uma nova notificação + */ + public function create(NotificationDocument $notification): void + { + $this->documentManager->persist($notification); + $this->documentManager->flush(); + } } diff --git a/src/Regmel/Command/CleanOldExportsCommand.php b/src/Regmel/Command/CleanOldExportsCommand.php new file mode 100644 index 000000000..9123daa3b --- /dev/null +++ b/src/Regmel/Command/CleanOldExportsCommand.php @@ -0,0 +1,63 @@ +parameterBag->get('kernel.project_dir') + ); + + if (!is_dir($exportsDir)) { + $io->warning('Diretório de exports não existe'); + return Command::SUCCESS; + } + + $maxAge = 48 * 3600; // 48 horas + $now = time(); + $deletedCount = 0; + $totalSize = 0; + + foreach (glob($exportsDir . '/*.zip') as $file) { + if (is_file($file) && ($now - filemtime($file)) > $maxAge) { + $size = filesize($file); + unlink($file); + $deletedCount++; + $totalSize += $size; + $io->writeln("✓ Removido: " . basename($file) . " (" . round($size/1024/1024, 2) . " MB)"); + } + } + + $io->success(sprintf( + 'Removidos %d arquivo(s) antigo(s) - Total: %s MB liberados', + $deletedCount, + round($totalSize/1024/1024, 2) + )); + + return Command::SUCCESS; + } +} diff --git a/src/Regmel/Controller/Web/Admin/ExportsAdminController.php b/src/Regmel/Controller/Web/Admin/ExportsAdminController.php new file mode 100644 index 000000000..794d8a65b --- /dev/null +++ b/src/Regmel/Controller/Web/Admin/ExportsAdminController.php @@ -0,0 +1,54 @@ +value.'") or + is_granted("'.UserRolesEnum::ROLE_MANAGER->value.'") or + is_granted("'.UserRolesEnum::ROLE_SUPPORT->value.'") or + is_granted("'.UserRolesEnum::ROLE_MUNICIPALITY->value.'") + '), statusCode: self::ACCESS_DENIED_RESPONSE_CODE)] + #[Route('/painel/admin/exports/{filename}', name: 'admin_regmel_exports_download', methods: ['GET'])] + public function downloadExport(string $filename): BinaryFileResponse + { + $exportsDir = sprintf( + '%s/storage/regmel/exports', + $this->getParameter('kernel.project_dir') + ); + + $filePath = sprintf('%s/%s', $exportsDir, $filename); + + // Validação de segurança contra path traversal + $realExportsDir = realpath($exportsDir); + $realFilePath = realpath($filePath); + + if (!$realFilePath || !str_starts_with($realFilePath, $realExportsDir)) { + throw $this->createNotFoundException('Arquivo não encontrado'); + } + + if (!file_exists($filePath)) { + throw $this->createNotFoundException('Arquivo não encontrado ou expirado'); + } + + $response = new BinaryFileResponse($filePath); + $response->setContentDisposition( + ResponseHeaderBag::DISPOSITION_ATTACHMENT, + $filename + ); + + // Não deleta após enviar, pois outras pessoas podem precisar + return $response; + } +} diff --git a/src/Regmel/Controller/Web/Admin/ProposalAdminController.php b/src/Regmel/Controller/Web/Admin/ProposalAdminController.php index cec9e3b6b..387f8ec80 100644 --- a/src/Regmel/Controller/Web/Admin/ProposalAdminController.php +++ b/src/Regmel/Controller/Web/Admin/ProposalAdminController.php @@ -25,10 +25,12 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\ResponseHeaderBag; +use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\Uid\Uuid; use Symfony\Contracts\Translation\TranslatorInterface; +use App\Regmel\Message\GenerateMapFilesZipMessage; class ProposalAdminController extends AbstractAdminController { @@ -45,6 +47,7 @@ public function __construct( private readonly TranslatorInterface $translator, private readonly EntityManagerInterface $entityManager, public readonly InscriptionOpportunityServiceInterface $inscriptionOpportunityService, + private readonly MessageBusInterface $messageBus, ) { } @@ -280,25 +283,35 @@ public function exportProjectFiles(): Response #[Route('/painel/admin/propostas/list/download-map-files', name: 'admin_regmel_proposal_map_file_download', methods: ['GET'])] public function exportMapFiles(): Response { - $user = $this->security->getUser(); + + // Verifica se é admin ou manager + if (!$this->security->isGranted(UserRolesEnum::ROLE_ADMIN->value) && + !$this->security->isGranted(UserRolesEnum::ROLE_MANAGER->value)) { + $this->addFlash('error', 'Apenas administradores podem exportar mapas.'); + return $this->redirectToRoute('admin_regmel_proposal_list'); + } + $isMunicipality = $this->security->isGranted(UserRolesEnum::ROLE_MUNICIPALITY->value); + $municipalityId = null; if ($isMunicipality) { $agent = $user->getAgents()->filter(fn($agent) => $agent->isMain())->first(); - $municipality = $agent->getOrganizations()->first(); - $initiatives = $this->initiativeService->list(limit: 10000, params: ['organizationTo' => $municipality]); - } else { - $initiatives = $this->initiativeService->list(limit: 10000); + $municipality = $agent?->getOrganizations()->first(); + $municipalityId = $municipality?->getId()->toRfc4122(); } - $zipFileName = sprintf('mapas_poligonais_%s.zip', date('Y-m-d_H-i-s')); - - $filePath = $this->proposalService->exportMapFiles($initiatives); + // Despacha mensagem assíncrona + $this->messageBus->dispatch(new GenerateMapFilesZipMessage( + userId: $user->getId()->toRfc4122(), + municipalityId: $municipalityId, + )); - $response = new BinaryFileResponse($filePath, headers: ['Content-Type' => 'application/zip']); - $response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $zipFileName); - $response->deleteFileAfterSend(true); + $this->addFlash( + 'success', + 'Exportação iniciada! Você será notificado quando o arquivo estiver pronto.' + ); + return $this->redirectToRoute('admin_dashboard') return $response; } diff --git a/src/Regmel/Message/GenerateMapFilesZipMessage.php b/src/Regmel/Message/GenerateMapFilesZipMessage.php new file mode 100644 index 000000000..00c36cc47 --- /dev/null +++ b/src/Regmel/Message/GenerateMapFilesZipMessage.php @@ -0,0 +1,13 @@ +logger->info('Iniciando geração de ZIP de mapas', [ + 'userId' => $message->userId, + 'municipalityId' => $message->municipalityId, + ]); + + // Busca as iniciativas + $params = []; + if ($message->municipalityId) { + // Filtro por município se necessário + // $params['organizationTo'] = $message->municipalityId; + } + + $initiatives = $this->initiativeService->list(limit: 10000, params: $params); + + // Gera o ZIP + $zipData = $this->proposalService->exportMapFilesAsync($initiatives, $message->userId); + + // Busca usuário + $user = $this->userRepository->find($message->userId); + if (!$user) { + throw new \RuntimeException('Usuário não encontrado'); + } + + // Gera URL de download + $downloadUrl = $this->urlGenerator->generate( + 'admin_regmel_exports_download', + ['filename' => basename($zipData['path'])], + UrlGeneratorInterface::ABSOLUTE_URL + ); + + // Cria notificação no sistema + $notification = new NotificationDocument(); + $notification->setSender('system'); + $notification->setTarget($message->userId); + $notification->setContent('Exportação de Mapas Poligonais concluída'); + $notification->setContext(sprintf( + '%d arquivos | Baixar ZIP', + $zipData['fileCount'], + $downloadUrl + )); + $notification->setCreatedAt(new DateTime()); + $notification->setVisited(false); + + // Salva notificação + $this->notificationService->create($notification); + + $this->logger->info('ZIP de mapas gerado com sucesso', [ + 'zipPath' => $zipData['path'], + 'fileCount' => $zipData['fileCount'], + ]); + + } catch (\Throwable $e) { + $this->logger->error('Erro ao gerar ZIP de mapas', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + // Notifica erro + try { + $errorNotification = new NotificationDocument(); + $errorNotification->setSender('system'); + $errorNotification->setTarget($message->userId); + $errorNotification->setContent('❌ Erro ao gerar exportação de Mapas Poligonais'); + $errorNotification->setContext('Entre em contato com o suporte técnico.'); + $errorNotification->setCreatedAt(new DateTime()); + $errorNotification->setVisited(false); + + $this->notificationService->create($errorNotification); + } catch (\Throwable $notifError) { + $this->logger->error('Erro ao criar notificação de erro', [ + 'error' => $notifError->getMessage() + ]); + } + + throw $e; + } + } +} diff --git a/src/Regmel/Service/Interface/ProposalServiceInterface.php b/src/Regmel/Service/Interface/ProposalServiceInterface.php index e9b36674b..db5e74483 100644 --- a/src/Regmel/Service/Interface/ProposalServiceInterface.php +++ b/src/Regmel/Service/Interface/ProposalServiceInterface.php @@ -30,6 +30,8 @@ public function exportProjectFiles(array $proposals): string; public function exportMapFiles(array $proposals): string; + public function exportMapFilesAsync(array $proposals, string $userId): array; + public function exportAnticipationFiles(array $proposals): string; public function bulkUpdateStatus(array $proposals, string $status): void; diff --git a/src/Regmel/Service/ProposalService.php b/src/Regmel/Service/ProposalService.php index 04685cdd1..cbc6623fc 100644 --- a/src/Regmel/Service/ProposalService.php +++ b/src/Regmel/Service/ProposalService.php @@ -409,7 +409,68 @@ public function exportMapFiles(array $proposals): string return $zipFilePath; } + public function exportMapFilesAsync(array $proposals, string $userId): array + { + // Cria diretório de exports se não existe + $exportsDir = sprintf( + '%s/storage/regmel/exports', + $this->parameterBag->get('kernel.project_dir') + ); + + if (!is_dir($exportsDir)) { + mkdir($exportsDir, 0755, true); + } + + // Limpa ZIPs antigos antes de criar novo (mais de 48h) + $this->cleanOldExports($exportsDir, 48 * 3600); + + // Gera nome único do arquivo + $timestamp = date('Y-m-d_H-i-s'); + $zipFileName = sprintf('mapas_poligonais_%s_%s.zip', substr($userId, 0, 8), $timestamp); + $zipFilePath = sprintf('%s/%s', $exportsDir, $zipFileName); + + $zip = new ZipArchive(); + if (true !== $zip->open($zipFilePath, ZipArchive::CREATE | ZipArchive::OVERWRITE)) { + throw new UnableCreateFileException(); + } + + $fileCount = 0; + foreach ($proposals as $proposal) { + if (empty($proposal->getExtraFields()['map_file'])) { + continue; + } + $filePath = $this->getDocumentPath($proposal->getExtraFields()['map_file']); + if (file_exists($filePath)) { + $zip->addFile($filePath, basename($filePath)); + $fileCount++; + } + } + + $zip->close(); + + return [ + 'path' => $zipFilePath, + 'filename' => $zipFileName, + 'fileCount' => $fileCount, + ]; + } + + private function cleanOldExports(string $dir, int $maxAge): void + { + if (!is_dir($dir)) { + return; + } + + $now = time(); + foreach (glob($dir . '/mapas_poligonais_*.zip') as $file) { + if (is_file($file) && ($now - filemtime($file)) > $maxAge) { + unlink($file); + } + } + } + public function exportAnticipationFiles(array $proposals): string + { $zipFilePath = sprintf('%s/storage/regmel/company/documents/anticipation_export.zip', $this->parameterBag->get('kernel.project_dir')); $zip = new ZipArchive(); From a1d73a651dff7d7ffd145d2900f496811350687e Mon Sep 17 00:00:00 2001 From: Victor Ferreira Date: Wed, 4 Feb 2026 14:48:30 -0300 Subject: [PATCH 2/8] =?UTF-8?q?fix:=20corrige=20erro=20no=20m=C3=A9todo=20?= =?UTF-8?q?exportMapFiles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Regmel/Controller/Web/Admin/ProposalAdminController.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Regmel/Controller/Web/Admin/ProposalAdminController.php b/src/Regmel/Controller/Web/Admin/ProposalAdminController.php index 387f8ec80..238c91ebd 100644 --- a/src/Regmel/Controller/Web/Admin/ProposalAdminController.php +++ b/src/Regmel/Controller/Web/Admin/ProposalAdminController.php @@ -283,6 +283,7 @@ public function exportProjectFiles(): Response #[Route('/painel/admin/propostas/list/download-map-files', name: 'admin_regmel_proposal_map_file_download', methods: ['GET'])] public function exportMapFiles(): Response { + $user = $this->security->getUser(); // Verifica se é admin ou manager if (!$this->security->isGranted(UserRolesEnum::ROLE_ADMIN->value) && @@ -311,8 +312,7 @@ public function exportMapFiles(): Response 'Exportação iniciada! Você será notificado quando o arquivo estiver pronto.' ); - return $this->redirectToRoute('admin_dashboard') - return $response; + return $this->redirectToRoute('admin_dashboard'); } #[IsGranted(new Expression(' From 7a8d5ab8caa299a86bf1415e4cd79ac44e1e8efe Mon Sep 17 00:00:00 2001 From: Victor Ferreira Date: Wed, 4 Feb 2026 17:51:26 -0300 Subject: [PATCH 3/8] Add symfony/messenger and symfony/doctrine-messenger packages --- composer.json | 2 + composer.lock | 169 +++++++++++++++++++++++++++++++++++++++++++++++++- symfony.lock | 12 ++++ 3 files changed, 182 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 8d522391e..5714c49e6 100644 --- a/composer.json +++ b/composer.json @@ -26,11 +26,13 @@ "symfony/asset": "7.2.*", "symfony/asset-mapper": "7.2.*", "symfony/console": "7.2.*", + "symfony/doctrine-messenger": "7.2.*", "symfony/dotenv": "7.2.*", "symfony/expression-language": "7.2.*", "symfony/flex": "^2", "symfony/framework-bundle": "7.2.*", "symfony/mailer": "7.2.*", + "symfony/messenger": "7.2.*", "symfony/mime": "7.2.*", "symfony/monolog-bundle": "^3.10", "symfony/property-access": "7.2.*", diff --git a/composer.lock b/composer.lock index 5e72d2633..b749a03e8 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "03322ffb12a7d01fd1fb39d799da708e", + "content-hash": "e8d7d3c472b0000f84868988176ae54f", "packages": [ { "name": "composer/pcre", @@ -4261,6 +4261,82 @@ ], "time": "2025-07-15T09:47:02+00:00" }, + { + "name": "symfony/doctrine-messenger", + "version": "v7.2.9", + "source": { + "type": "git", + "url": "https://github.com/symfony/doctrine-messenger.git", + "reference": "368de12551994631816e7d72d958e0a58a218fd9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/doctrine-messenger/zipball/368de12551994631816e7d72d958e0a58a218fd9", + "reference": "368de12551994631816e7d72d958e0a58a218fd9", + "shasum": "" + }, + "require": { + "doctrine/dbal": "^3.6|^4", + "php": ">=8.2", + "symfony/messenger": "^6.4|^7.0", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "doctrine/persistence": "<1.3" + }, + "require-dev": { + "doctrine/persistence": "^1.3|^2|^3", + "symfony/property-access": "^6.4|^7.0", + "symfony/serializer": "^6.4|^7.0" + }, + "type": "symfony-messenger-bridge", + "autoload": { + "psr-4": { + "Symfony\\Component\\Messenger\\Bridge\\Doctrine\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Doctrine Messenger Bridge", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/doctrine-messenger/tree/v7.2.9" + }, + "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": "2025-07-30T17:03:27+00:00" + }, { "name": "symfony/dotenv", "version": "v7.2.9", @@ -5468,6 +5544,97 @@ ], "time": "2025-07-15T11:30:57+00:00" }, + { + "name": "symfony/messenger", + "version": "v7.2.9", + "source": { + "type": "git", + "url": "https://github.com/symfony/messenger.git", + "reference": "3fd1638aa11ef6f342ed6b10b930a1757c25417c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/messenger/zipball/3fd1638aa11ef6f342ed6b10b930a1757c25417c", + "reference": "3fd1638aa11ef6f342ed6b10b930a1757c25417c", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/clock": "^6.4|^7.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/console": "<7.2", + "symfony/event-dispatcher": "<6.4", + "symfony/event-dispatcher-contracts": "<2.5", + "symfony/framework-bundle": "<6.4", + "symfony/http-kernel": "<6.4", + "symfony/serializer": "<6.4" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/console": "^7.2", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/event-dispatcher": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/process": "^6.4|^7.0", + "symfony/property-access": "^6.4|^7.0", + "symfony/rate-limiter": "^6.4|^7.0", + "symfony/routing": "^6.4|^7.0", + "symfony/serializer": "^6.4|^7.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^6.4|^7.0", + "symfony/validator": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Messenger\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Samuel Roze", + "email": "samuel.roze@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps applications send and receive messages to/from other applications or via message queues", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/messenger/tree/v7.2.9" + }, + "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": "2025-07-15T11:30:57+00:00" + }, { "name": "symfony/mime", "version": "v7.2.9", diff --git a/symfony.lock b/symfony.lock index 52742ccef..67fc6fe22 100644 --- a/symfony.lock +++ b/symfony.lock @@ -218,6 +218,18 @@ "ref": "fadbfe33303a76e25cb63401050439aa9b1a9c7f" } }, + "symfony/messenger": { + "version": "7.2", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "6.0", + "ref": "d8936e2e2230637ef97e5eecc0eea074eecae58b" + }, + "files": [ + "config/packages/messenger.yaml" + ] + }, "symfony/monolog-bundle": { "version": "3.10", "recipe": { From 2ab24d6b18d95b32e1f605aa0045485123c01c5a Mon Sep 17 00:00:00 2001 From: Victor Ferreira Date: Thu, 5 Feb 2026 00:44:50 -0300 Subject: [PATCH 4/8] =?UTF-8?q?Habilitar=20=C3=A1rea=20de=20notifica=C3=A7?= =?UTF-8?q?=C3=B5es=20na=20navbar=20apenas=20para=20admins?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- templates/_components/navbar.html.twig | 88 +++++++++++++------------- 1 file changed, 45 insertions(+), 43 deletions(-) diff --git a/templates/_components/navbar.html.twig b/templates/_components/navbar.html.twig index 1aa231ffe..a5a1cf261 100644 --- a/templates/_components/navbar.html.twig +++ b/templates/_components/navbar.html.twig @@ -36,53 +36,55 @@ {% if app.user %}
- {# + {% endif %}
{% endif %} From 83f009d3db71f979f8b98589f914cf184337a2a6 Mon Sep 17 00:00:00 2001 From: Victor Ferreira Date: Thu, 5 Feb 2026 01:03:12 -0300 Subject: [PATCH 5/8] =?UTF-8?q?Implementar=20limpeza=20autom=C3=A1tica=20d?= =?UTF-8?q?e=20arquivos=20ZIP=20de=20exporta=C3=A7=C3=A3o=20com=20expira?= =?UTF-8?q?=C3=A7=C3=A3o=20de=202=20horas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/js/notifications.js | 42 +++ helm/pvr/templates/cronjob-cleanup.yaml | 73 +++++ helm/pvr/templates/pvc-exports.yaml | 19 ++ help/CLEANUP-EXPORTS.md | 271 ++++++++++++++++++ src/Command/CleanupExportFilesCommand.php | 149 ++++++++++ .../GenerateMapFilesZipMessageHandler.php | 11 +- 6 files changed, 562 insertions(+), 3 deletions(-) create mode 100644 assets/js/notifications.js create mode 100644 helm/pvr/templates/cronjob-cleanup.yaml create mode 100644 helm/pvr/templates/pvc-exports.yaml create mode 100644 help/CLEANUP-EXPORTS.md create mode 100644 src/Command/CleanupExportFilesCommand.php diff --git a/assets/js/notifications.js b/assets/js/notifications.js new file mode 100644 index 000000000..f35b936ba --- /dev/null +++ b/assets/js/notifications.js @@ -0,0 +1,42 @@ +/** + * Gerenciamento de notificações - verifica expiração de downloads + */ +document.addEventListener('DOMContentLoaded', function() { + checkExpiredDownloads(); + + // Verifica a cada minuto + setInterval(checkExpiredDownloads, 60000); +}); + +function checkExpiredDownloads() { + const downloadLinks = document.querySelectorAll('a[data-expires-at]'); + const now = new Date(); + + downloadLinks.forEach(link => { + const expiresAt = new Date(link.dataset.expiresAt); + + if (now > expiresAt) { + // Arquivo expirou + link.classList.remove('btn-primary'); + link.classList.add('btn-secondary', 'disabled'); + link.setAttribute('aria-disabled', 'true'); + link.style.pointerEvents = 'none'; + link.innerHTML = 'Arquivo Expirado'; + + // Atualiza mensagem ao lado + const parent = link.parentElement; + const expiredMsg = parent.querySelector('.text-muted'); + if (expiredMsg) { + expiredMsg.innerHTML = '(expirado - gere novamente)'; + } + } else { + // Mostra tempo restante + const timeLeft = Math.floor((expiresAt - now) / 1000 / 60); // minutos + const expiredMsg = link.parentElement.querySelector('.text-muted'); + + if (expiredMsg && timeLeft < 60) { + expiredMsg.innerHTML = `(expira em ${timeLeft} min)`; + } + } + }); +} diff --git a/helm/pvr/templates/cronjob-cleanup.yaml b/helm/pvr/templates/cronjob-cleanup.yaml new file mode 100644 index 000000000..116a93ab0 --- /dev/null +++ b/helm/pvr/templates/cronjob-cleanup.yaml @@ -0,0 +1,73 @@ +apiVersion: batch/v1 +kind: CronJob +metadata: + name: pvr-cleanup-export-files + namespace: default + labels: + app: pvr + component: cleanup +spec: + # Executa a cada 2 horas (no minuto 0) + schedule: "0 */2 * * *" + + # Manter histórico dos últimos 3 jobs + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 3 + + # Política de concorrência: não permitir múltiplas execuções simultâneas + concurrencyPolicy: Forbid + + jobTemplate: + metadata: + labels: + app: pvr + component: cleanup-job + spec: + # Manter pod por 1 hora após conclusão para análise de logs + ttlSecondsAfterFinished: 3600 + + template: + metadata: + labels: + app: pvr + component: cleanup-job + spec: + restartPolicy: Never + + containers: + - name: cleanup + # Usar a mesma imagem do PHP da aplicação + image: pvr-php:latest + imagePullPolicy: IfNotPresent + + command: + - php + - bin/console + - app:cleanup-export-files + - --max-age=2 + + # Variáveis de ambiente (mesmas da aplicação) + envFrom: + - configMapRef: + name: pvr-config + - secretRef: + name: pvr-secrets + + # Montagem do volume de exportação + volumeMounts: + - name: exports-volume + mountPath: /app/var/exports + + # Recursos limitados (job leve) + resources: + requests: + memory: "64Mi" + cpu: "50m" + limits: + memory: "128Mi" + cpu: "100m" + + volumes: + - name: exports-volume + persistentVolumeClaim: + claimName: pvr-exports-pvc diff --git a/helm/pvr/templates/pvc-exports.yaml b/helm/pvr/templates/pvc-exports.yaml new file mode 100644 index 000000000..33b258404 --- /dev/null +++ b/helm/pvr/templates/pvc-exports.yaml @@ -0,0 +1,19 @@ +{{- if .Values.exports.persistence.enabled }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "pvr.fullname" . }}-exports + labels: + {{- include "pvr.labels" . | nindent 4 }} + component: exports +spec: + accessModes: + - {{ .Values.exports.persistence.accessMode | default "ReadWriteMany" }} + resources: + requests: + storage: {{ .Values.exports.persistence.size | default "5Gi" }} + {{- if .Values.exports.persistence.storageClass }} + storageClassName: {{ .Values.exports.persistence.storageClass }} + {{- end }} +{{- end }} + diff --git a/help/CLEANUP-EXPORTS.md b/help/CLEANUP-EXPORTS.md new file mode 100644 index 000000000..1298bbb12 --- /dev/null +++ b/help/CLEANUP-EXPORTS.md @@ -0,0 +1,271 @@ +# Sistema de Limpeza Automática de Arquivos de Exportação + +## Visão Geral + +Sistema automatizado para gerenciar arquivos ZIP de exportação de mapas poligonais, evitando acúmulo de espaço em disco. + +**Estratégia**: Arquivos expiram em **2 horas** e são deletados automaticamente a cada 2 horas via CronJob. + +## Componentes + +### 1. Comando de Limpeza + +**Arquivo**: `src/Command/CleanupExportFilesCommand.php` + +#### Uso Manual + +```bash +# Deletar arquivos com mais de 2 horas +php bin/console app:cleanup-export-files + +# Deletar arquivos com mais de 6 horas +php bin/console app:cleanup-export-files --max-age=6 + +# Simular (dry-run) sem deletar +php bin/console app:cleanup-export-files --dry-run + +# Modo verboso +php bin/console app:cleanup-export-files -v +``` + +#### Uso no Kubernetes + +```bash +# Executar manualmente no pod +kubectl exec -it -- php bin/console app:cleanup-export-files + +# Ver logs do CronJob +kubectl logs -l component=cleanup-job + +# Ver CronJobs configurados +kubectl get cronjobs + +# Ver histórico de jobs +kubectl get jobs | grep cleanup +``` + +### 2. CronJob Kubernetes + +**Arquivo**: `helm/pvr/templates/cronjob-cleanup.yaml` + +- **Frequência**: A cada 2 horas (00:00, 02:00, 04:00, etc.) +- **Idade máxima**: 2 horas +- **Política**: Não permite execuções simultâneas + +#### Customizar Frequência + +Editar `schedule` no CronJob: + +```yaml +# A cada 1 hora +schedule: "0 * * * *" + +# A cada 4 horas +schedule: "0 */4 * * *" + +# Diariamente às 3h da manhã +schedule: "0 3 * * *" +``` + +### 3. Notificação de Expiração + +**Arquivo**: `assets/js/notifications.js` + +Monitora notificações com links de download e: +- Mostra tempo restante antes de expirar +- Desabilita botão quando arquivo expira +- Atualiza automaticamente a cada minuto + +### 4. Armazenamento Persistente + +**Arquivo**: `helm/pvr/templates/pvc-exports.yaml` + +PersistentVolumeClaim compartilhado entre pods para `/var/exports`. + +## Fluxo Completo + +``` +1. Usuário solicita download → ZIP gerado em /var/exports + ↓ +2. Notificação criada com link + timestamp expiração (2h) + ↓ +3. JavaScript monitora expiração na interface + ↓ +4. A cada 2 horas: CronJob executa comando de limpeza + ↓ +5. Arquivos com +2h são deletados automaticamente +``` + +## Configuração + +### Helm Values + +Adicionar em `helm/pvr/values.yaml`: + +```yaml +exports: + persistence: + enabled: true + size: 5Gi + accessMode: ReadWriteMany + # storageClass: fast-ssd (opcional) + +cronjob: + cleanup: + enabled: true + schedule: "0 */2 * * *" + maxAge: 2 # horas +``` + +### Deploy + +```bash +# Com Helm +helm upgrade --install pvr ./helm/pvr -f values-production.yaml + +# Com Skaffold +skaffold run +``` + +## Monitoramento + +### Logs do CronJob + +```bash +# Últimos logs +kubectl logs -l component=cleanup-job --tail=100 + +# Seguir logs em tempo real +kubectl logs -l component=cleanup-job -f + +# Logs de job específico +kubectl logs job/pvr-cleanup-export-files-28475920 +``` + +### Métricas + +O comando gera logs estruturados com: +- Quantidade de arquivos deletados +- Espaço liberado +- Arquivos mantidos +- Erros encontrados + +### Alertas + +Configurar alertas no Prometheus/Grafana para: +- Jobs com falha +- Uso de disco em `/var/exports` +- Tempo de execução anormal + +## Troubleshooting + +### CronJob não está executando + +```bash +# Verificar se CronJob existe +kubectl get cronjob pvr-cleanup-export-files + +# Verificar última execução +kubectl get jobs | grep cleanup + +# Ver eventos +kubectl describe cronjob pvr-cleanup-export-files +``` + +### Arquivos não sendo deletados + +```bash +# Executar manualmente com verbose +kubectl exec -it -- php bin/console app:cleanup-export-files -v + +# Verificar permissões do diretório +kubectl exec -it -- ls -la /app/var/exports + +# Verificar espaço em disco +kubectl exec -it -- df -h /app/var/exports +``` + +### Volume não está montado + +```bash +# Verificar PVC +kubectl get pvc + +# Verificar volume no pod +kubectl describe pod | grep -A5 "Volumes:" + +# Criar PVC se não existir +kubectl apply -f helm/pvr/templates/pvc-exports.yaml +``` + +## Testes + +### Testar Localmente (Docker Compose) + +```bash +# Entrar no container +make shell + +# Criar alguns arquivos de teste +touch var/exports/test-old-{1..5}.zip +touch var/exports/test-new.zip + +# Ajustar data de modificação (simular arquivos antigos) +find var/exports -name "test-old-*.zip" -exec touch -t 202602010000 {} \; + +# Executar limpeza (dry-run) +php bin/console app:cleanup-export-files --dry-run -v + +# Executar limpeza real +php bin/console app:cleanup-export-files -v +``` + +### Testar no Kubernetes + +```bash +# Criar Job manual (one-time) +kubectl create job --from=cronjob/pvr-cleanup-export-files manual-cleanup-test + +# Acompanhar execução +kubectl logs -f job/manual-cleanup-test + +# Limpar job de teste +kubectl delete job manual-cleanup-test +``` + +## Manutenção + +### Ajustar Tempo de Expiração + +1. **Backend** (`GenerateMapFilesZipMessageHandler.php`): + ```php + $expiresAt = (clone $createdAt)->modify('+4 hours'); // alterar aqui + ``` + +2. **CronJob** (`cronjob-cleanup.yaml`): + ```yaml + - --max-age=4 # alterar aqui + ``` + +3. **Interface** (mensagem no handler): + ```php + '(expira em 4h)' // alterar aqui + ``` + +### Aumentar Espaço de Armazenamento + +```bash +# Editar PVC +kubectl edit pvc pvr-exports-pvc + +# Ou via Helm values +helm upgrade pvr ./helm/pvr --set exports.persistence.size=10Gi +``` + +## Melhorias Futuras + +- [ ] Deletar arquivo imediatamente após download bem-sucedido +- [ ] Alertar usuário quando arquivo está próximo de expirar (push notification) +- [ ] Estatísticas de uso (quantos arquivos gerados/dia, espaço total) +- [ ] Compressão adicional para arquivos grandes +- [ ] Storage S3 para arquivos temporários (evitar uso de disco local) diff --git a/src/Command/CleanupExportFilesCommand.php b/src/Command/CleanupExportFilesCommand.php new file mode 100644 index 000000000..e8e1cbbf0 --- /dev/null +++ b/src/Command/CleanupExportFilesCommand.php @@ -0,0 +1,149 @@ +addOption( + 'max-age', + null, + InputOption::VALUE_REQUIRED, + 'Idade máxima dos arquivos em horas (padrão: 2)', + '2' + ) + ->addOption( + 'dry-run', + null, + InputOption::VALUE_NONE, + 'Simular exclusão sem deletar arquivos' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $maxAgeHours = (int) $input->getOption('max-age'); + $dryRun = $input->getOption('dry-run'); + + $exportDir = $this->parameterBag->get('kernel.project_dir') . '/var/exports'; + + if (!is_dir($exportDir)) { + $io->warning("Diretório de exportação não existe: {$exportDir}"); + return Command::SUCCESS; + } + + $maxAgeSeconds = $maxAgeHours * 3600; + $now = time(); + $deletedCount = 0; + $deletedSize = 0; + $keptCount = 0; + + $io->title('Limpeza de Arquivos de Exportação'); + $io->text([ + "Diretório: {$exportDir}", + "Idade máxima: {$maxAgeHours}h", + "Modo: " . ($dryRun ? 'SIMULAÇÃO (dry-run)' : 'REAL'), + ]); + $io->newLine(); + + $files = glob($exportDir . '/*.zip'); + + if (empty($files)) { + $io->success('Nenhum arquivo ZIP encontrado.'); + return Command::SUCCESS; + } + + $io->section("Analisando {$files} arquivos..."); + + foreach ($files as $file) { + $fileAge = $now - filemtime($file); + $fileSize = filesize($file); + $fileName = basename($file); + $ageHours = round($fileAge / 3600, 1); + + if ($fileAge > $maxAgeSeconds) { + if ($dryRun) { + $io->text("🔍 [DRY-RUN] Deletaria: {$fileName} ({$ageHours}h, " . $this->formatBytes($fileSize) . ")"); + } else { + if (unlink($file)) { + $io->text("✅ Deletado: {$fileName} ({$ageHours}h, " . $this->formatBytes($fileSize) . ")"); + $deletedCount++; + $deletedSize += $fileSize; + + $this->logger->info('Arquivo de exportação deletado', [ + 'file' => $fileName, + 'age_hours' => $ageHours, + 'size_bytes' => $fileSize, + ]); + } else { + $io->error("❌ Erro ao deletar: {$fileName}"); + $this->logger->error('Falha ao deletar arquivo de exportação', [ + 'file' => $fileName, + ]); + } + } + } else { + $keptCount++; + if ($output->isVerbose()) { + $io->text("⏳ Mantido: {$fileName} ({$ageHours}h, " . $this->formatBytes($fileSize) . ")"); + } + } + } + + $io->newLine(); + $io->section('Resumo'); + $io->table( + ['Métrica', 'Valor'], + [ + ['Arquivos deletados', $dryRun ? "{$deletedCount} (simulação)" : $deletedCount], + ['Espaço liberado', $this->formatBytes($deletedSize)], + ['Arquivos mantidos', $keptCount], + ['Total analisado', count($files)], + ] + ); + + if ($dryRun) { + $io->warning('Modo DRY-RUN ativo. Nenhum arquivo foi deletado. Execute sem --dry-run para deletar.'); + } else { + $io->success('Limpeza concluída com sucesso!'); + } + + return Command::SUCCESS; + } + + private function formatBytes(int $bytes): string + { + $units = ['B', 'KB', 'MB', 'GB']; + $bytes = max($bytes, 0); + $pow = floor(($bytes ? log($bytes) : 0) / log(1024)); + $pow = min($pow, count($units) - 1); + $bytes /= (1 << (10 * $pow)); + + return round($bytes, 2) . ' ' . $units[$pow]; + } +} diff --git a/src/Regmel/MessageHandler/GenerateMapFilesZipMessageHandler.php b/src/Regmel/MessageHandler/GenerateMapFilesZipMessageHandler.php index 195a09ae6..d3fe37c26 100644 --- a/src/Regmel/MessageHandler/GenerateMapFilesZipMessageHandler.php +++ b/src/Regmel/MessageHandler/GenerateMapFilesZipMessageHandler.php @@ -60,17 +60,22 @@ public function __invoke(GenerateMapFilesZipMessage $message): void UrlGeneratorInterface::ABSOLUTE_URL ); + // Calcula timestamp de expiração (2 horas) + $createdAt = new DateTime(); + $expiresAt = (clone $createdAt)->modify('+2 hours'); + // Cria notificação no sistema $notification = new NotificationDocument(); $notification->setSender('system'); $notification->setTarget($message->userId); $notification->setContent('Exportação de Mapas Poligonais concluída'); $notification->setContext(sprintf( - '%d arquivos | Baixar ZIP', + '%d arquivos | Baixar ZIP (expira em 2h)', $zipData['fileCount'], - $downloadUrl + $downloadUrl, + $expiresAt->format('Y-m-d H:i:s') )); - $notification->setCreatedAt(new DateTime()); + $notification->setCreatedAt($createdAt); $notification->setVisited(false); // Salva notificação From a5ccd1b0dece417a02b488d114a81430593d2f8e Mon Sep 17 00:00:00 2001 From: Victor Ferreira Date: Tue, 3 Mar 2026 15:17:08 -0300 Subject: [PATCH 6/8] =?UTF-8?q?feat:=20implementa=20exporta=C3=A7=C3=A3o?= =?UTF-8?q?=20ass=C3=ADncrona=20de=20anu=C3=AAncias=20com=20expira=C3=A7?= =?UTF-8?q?=C3=A3o=20inteligente=20(30min=20ap=C3=B3s=20download=20ou=202h?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/packages/messenger.yaml | 1 + src/Regmel/Command/CleanOldExportsCommand.php | 45 +++++++- .../Web/Admin/ExportsAdminController.php | 6 + .../ProposalAgreementAdminController.php | 33 +++--- .../Message/GenerateAgreementsZipMessage.php | 12 ++ .../GenerateAgreementsZipMessageHandler.php | 103 ++++++++++++++++++ .../GenerateMapFilesZipMessageHandler.php | 2 +- .../ProposalAgreementServiceInterface.php | 5 + .../Service/ProposalAgreementService.php | 93 ++++++++++++++++ src/Regmel/Service/ProposalService.php | 31 +++++- templates/_components/navbar.html.twig | 2 +- .../admin/proposal/agreements.html.twig | 2 + 12 files changed, 312 insertions(+), 23 deletions(-) create mode 100644 src/Regmel/Message/GenerateAgreementsZipMessage.php create mode 100644 src/Regmel/MessageHandler/GenerateAgreementsZipMessageHandler.php diff --git a/config/packages/messenger.yaml b/config/packages/messenger.yaml index 2507c32b2..f51a502ba 100644 --- a/config/packages/messenger.yaml +++ b/config/packages/messenger.yaml @@ -16,3 +16,4 @@ framework: routing: 'App\Regmel\Message\GenerateMapFilesZipMessage': async + 'App\Regmel\Message\GenerateAgreementsZipMessage': async diff --git a/src/Regmel/Command/CleanOldExportsCommand.php b/src/Regmel/Command/CleanOldExportsCommand.php index 9123daa3b..54bbb517d 100644 --- a/src/Regmel/Command/CleanOldExportsCommand.php +++ b/src/Regmel/Command/CleanOldExportsCommand.php @@ -13,7 +13,7 @@ #[AsCommand( name: 'app:regmel:clean-old-exports', - description: 'Remove arquivos de exportação com mais de 48 horas', + description: 'Remove arquivos de exportação (30min após download ou 2h)', )] class CleanOldExportsCommand extends Command { @@ -37,18 +37,55 @@ protected function execute(InputInterface $input, OutputInterface $output): int return Command::SUCCESS; } - $maxAge = 48 * 3600; // 48 horas $now = time(); $deletedCount = 0; $totalSize = 0; foreach (glob($exportsDir . '/*.zip') as $file) { - if (is_file($file) && ($now - filemtime($file)) > $maxAge) { + if (!is_file($file)) { + continue; + } + + $downloadedMarkerPath = $file . '.downloaded'; + $shouldDelete = false; + $reason = ''; + + if (file_exists($downloadedMarkerPath)) { + // Se foi baixado, apagar após 30 minutos do download + $downloadTimestamp = (int) file_get_contents($downloadedMarkerPath); + $timeSinceDownload = $now - $downloadTimestamp; + + if ($timeSinceDownload > (30 * 60)) { // 30 minutos + $shouldDelete = true; + $reason = sprintf('baixado há %d min', round($timeSinceDownload / 60)); + } + } else { + // Se não foi baixado, apagar após 2 horas da criação + $timeSinceCreation = $now - filemtime($file); + + if ($timeSinceCreation > (2 * 3600)) { // 2 horas + $shouldDelete = true; + $reason = sprintf('criado há %d min (não baixado)', round($timeSinceCreation / 60)); + } + } + + if ($shouldDelete) { $size = filesize($file); unlink($file); + + // Remove arquivo .downloaded se existir + if (file_exists($downloadedMarkerPath)) { + unlink($downloadedMarkerPath); + } + $deletedCount++; $totalSize += $size; - $io->writeln("✓ Removido: " . basename($file) . " (" . round($size/1024/1024, 2) . " MB)"); + $io->writeln(sprintf( + "✓ Removido: %s (%s MB) - %s", + basename($file), + round($size/1024/1024, 2), + $reason + )); } } diff --git a/src/Regmel/Controller/Web/Admin/ExportsAdminController.php b/src/Regmel/Controller/Web/Admin/ExportsAdminController.php index 794d8a65b..b767bbdc2 100644 --- a/src/Regmel/Controller/Web/Admin/ExportsAdminController.php +++ b/src/Regmel/Controller/Web/Admin/ExportsAdminController.php @@ -42,6 +42,12 @@ public function downloadExport(string $filename): BinaryFileResponse throw $this->createNotFoundException('Arquivo não encontrado ou expirado'); } + // Marca timestamp de download para limpeza posterior + $downloadedMarkerPath = $filePath . '.downloaded'; + if (!file_exists($downloadedMarkerPath)) { + file_put_contents($downloadedMarkerPath, (string) time()); + } + $response = new BinaryFileResponse($filePath); $response->setContentDisposition( ResponseHeaderBag::DISPOSITION_ATTACHMENT, diff --git a/src/Regmel/Controller/Web/Admin/ProposalAgreementAdminController.php b/src/Regmel/Controller/Web/Admin/ProposalAgreementAdminController.php index e265c3cf2..e890548f5 100644 --- a/src/Regmel/Controller/Web/Admin/ProposalAgreementAdminController.php +++ b/src/Regmel/Controller/Web/Admin/ProposalAgreementAdminController.php @@ -7,6 +7,7 @@ use App\Controller\Web\Admin\AbstractAdminController; use App\Enum\RegionEnum; use App\Enum\UserRolesEnum; +use App\Regmel\Message\GenerateAgreementsZipMessage; use App\Regmel\Service\Interface\ProposalAgreementServiceInterface; use App\Service\Interface\StateServiceInterface; use Exception; @@ -16,6 +17,7 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\ResponseHeaderBag; +use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\Uid\Uuid; @@ -28,6 +30,7 @@ public function __construct( private readonly StateServiceInterface $stateService, private readonly Security $security, private readonly TranslatorInterface $translator, + private readonly MessageBusInterface $messageBus, ) { } @@ -165,23 +168,27 @@ public function cancelAgreement(Uuid $id, Request $request): Response return $this->redirectBack($request); } - #[IsGranted(UserRolesEnum::ROLE_ADMIN->value, statusCode: self::ACCESS_DENIED_RESPONSE_CODE)] + #[IsGranted(new Expression(' + is_granted("'.UserRolesEnum::ROLE_ADMIN->value.'") or + is_granted("'.UserRolesEnum::ROLE_SUPPORT->value.'") + '), statusCode: self::ACCESS_DENIED_RESPONSE_CODE)] #[Route('/painel/admin/propostas-anuencias/download', name: 'admin_regmel_proposal_agreement_download_all', methods: ['GET'])] public function downloadAllAgreements(): Response { - try { - $zipPath = $this->agreementService->exportAllAgreements(); - $zipFileName = basename($zipPath); - - $response = new BinaryFileResponse($zipPath, headers: ['Content-Type' => 'application/zip']); - $response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $zipFileName); - $response->deleteFileAfterSend(true); + /** @var \App\Entity\User $user */ + $user = $this->security->getUser(); + + // Despacha mensagem assíncrona + $this->messageBus->dispatch(new GenerateAgreementsZipMessage( + userId: $user->getId()->toRfc4122(), + )); + + $this->addFlash( + 'success', + 'Exportação iniciada! Você será notificado quando o arquivo estiver pronto.' + ); - return $response; - } catch (Exception $e) { - $this->addFlash('error', 'Erro ao gerar arquivo ZIP: ' . $e->getMessage()); - return $this->redirectToRoute('admin_regmel_proposal_agreement_list'); - } + return $this->redirectToRoute('admin_regmel_proposal_agreement_list'); } private function redirectBack(Request $request): Response diff --git a/src/Regmel/Message/GenerateAgreementsZipMessage.php b/src/Regmel/Message/GenerateAgreementsZipMessage.php new file mode 100644 index 000000000..17d239435 --- /dev/null +++ b/src/Regmel/Message/GenerateAgreementsZipMessage.php @@ -0,0 +1,12 @@ +logger->info('Iniciando geração de ZIP de anuências', [ + 'userId' => $message->userId, + ]); + + // Gera o ZIP + $zipData = $this->agreementService->exportAllAgreementsAsync($message->userId); + + // Busca usuário + $user = $this->userRepository->find($message->userId); + if (!$user) { + throw new \RuntimeException('Usuário não encontrado'); + } + + // Gera URL de download + $downloadUrl = $this->urlGenerator->generate( + 'admin_regmel_exports_download', + ['filename' => basename($zipData['path'])], + UrlGeneratorInterface::ABSOLUTE_URL + ); + + // Calcula timestamp de expiração (2 horas) + $createdAt = new DateTime(); + $expiresAt = (clone $createdAt)->modify('+2 hours'); + + // Cria notificação no sistema + $notification = new NotificationDocument(); + $notification->setSender('system'); + $notification->setTarget($message->userId); + $notification->setContent('Exportação de Anuências concluída'); + $notification->setContext(sprintf( + '%d arquivo(s) | Baixar ZIP (expira 30min após download ou 2h)', + $zipData['fileCount'], + $downloadUrl, + $expiresAt->format('Y-m-d H:i:s') + )); + $notification->setCreatedAt($createdAt); + $notification->setVisited(false); + + // Salva notificação + $this->notificationService->create($notification); + + $this->logger->info('ZIP de anuências gerado com sucesso', [ + 'zipPath' => $zipData['path'], + 'fileCount' => $zipData['fileCount'], + ]); + + } catch (\Throwable $e) { + $this->logger->error('Erro ao gerar ZIP de anuências', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + // Notifica erro + try { + $errorNotification = new NotificationDocument(); + $errorNotification->setSender('system'); + $errorNotification->setTarget($message->userId); + $errorNotification->setContent('❌ Erro ao gerar exportação de Anuências'); + $errorNotification->setContext('Entre em contato com o suporte técnico.'); + $errorNotification->setCreatedAt(new DateTime()); + $errorNotification->setVisited(false); + + $this->notificationService->create($errorNotification); + } catch (\Throwable $notifError) { + $this->logger->error('Erro ao criar notificação de erro', [ + 'error' => $notifError->getMessage() + ]); + } + + throw $e; + } + } +} diff --git a/src/Regmel/MessageHandler/GenerateMapFilesZipMessageHandler.php b/src/Regmel/MessageHandler/GenerateMapFilesZipMessageHandler.php index d3fe37c26..d92039d3c 100644 --- a/src/Regmel/MessageHandler/GenerateMapFilesZipMessageHandler.php +++ b/src/Regmel/MessageHandler/GenerateMapFilesZipMessageHandler.php @@ -70,7 +70,7 @@ public function __invoke(GenerateMapFilesZipMessage $message): void $notification->setTarget($message->userId); $notification->setContent('Exportação de Mapas Poligonais concluída'); $notification->setContext(sprintf( - '%d arquivos | Baixar ZIP (expira em 2h)', + '%d arquivos | Baixar ZIP (expira 30min após download ou 2h)', $zipData['fileCount'], $downloadUrl, $expiresAt->format('Y-m-d H:i:s') diff --git a/src/Regmel/Service/Interface/ProposalAgreementServiceInterface.php b/src/Regmel/Service/Interface/ProposalAgreementServiceInterface.php index a16750c11..5bd894b5d 100644 --- a/src/Regmel/Service/Interface/ProposalAgreementServiceInterface.php +++ b/src/Regmel/Service/Interface/ProposalAgreementServiceInterface.php @@ -34,6 +34,11 @@ public function getAgreementDocumentPath(Uuid $proposalId): ?string; */ public function exportAllAgreements(): string; + /** + * Generate ZIP with all agreement documents asynchronously. + */ + public function exportAllAgreementsAsync(string $userId): array; + /** * Send email notification when municipality uploads agreement. */ diff --git a/src/Regmel/Service/ProposalAgreementService.php b/src/Regmel/Service/ProposalAgreementService.php index 529d3fbb6..65b240a96 100644 --- a/src/Regmel/Service/ProposalAgreementService.php +++ b/src/Regmel/Service/ProposalAgreementService.php @@ -206,6 +206,99 @@ public function exportAllAgreements(): string return $zipPath; } + public function exportAllAgreementsAsync(string $userId): array + { + // Cria diretório de exports se não existe + $exportsDir = sprintf( + '%s/storage/regmel/exports', + $this->parameterBag->get('kernel.project_dir') + ); + + if (!is_dir($exportsDir)) { + mkdir($exportsDir, 0755, true); + } + + // Limpa ZIPs antigos antes de criar novo + $this->cleanOldExports($exportsDir); + + $proposals = $this->getProposalsAwaitingValidation(); + + // Gera nome único do arquivo + $timestamp = date('Y-m-d_H-i-s'); + $zipFileName = sprintf('anuencias_%s_%s.zip', substr($userId, 0, 8), $timestamp); + $zipFilePath = sprintf('%s/%s', $exportsDir, $zipFileName); + + $zip = new ZipArchive(); + if (true !== $zip->open($zipFilePath, ZipArchive::CREATE | ZipArchive::OVERWRITE)) { + throw new \RuntimeException('Não foi possível criar arquivo ZIP'); + } + + $fileCount = 0; + foreach ($proposals as $proposal) { + $filePath = $this->getAgreementDocumentPath($proposal->getId()); + if ($filePath && file_exists($filePath)) { + $extraFields = $proposal->getExtraFields(); + $municipalityName = $extraFields['city_name'] ?? 'Municipio'; + $companyName = $proposal->getOrganizationFrom()?->getName() ?? 'Empresa'; + + $zipEntryName = sprintf( + '%s_%s_%s', + $municipalityName, + $companyName, + basename($filePath) + ); + + $zip->addFile($filePath, $zipEntryName); + $fileCount++; + } + } + + $zip->close(); + + return [ + 'path' => $zipFilePath, + 'filename' => $zipFileName, + 'fileCount' => $fileCount, + ]; + } + + private function cleanOldExports(string $dir): void + { + if (!is_dir($dir)) { + return; + } + + $now = time(); + foreach (glob($dir . '/anuencias_*.zip') as $file) { + if (!is_file($file)) { + continue; + } + + $downloadedMarkerPath = $file . '.downloaded'; + $shouldDelete = false; + + if (file_exists($downloadedMarkerPath)) { + // Se foi baixado, apagar após 30 minutos do download + $downloadTimestamp = (int) file_get_contents($downloadedMarkerPath); + if (($now - $downloadTimestamp) > (30 * 60)) { + $shouldDelete = true; + } + } else { + // Se não foi baixado, apagar após 2 horas da criação + if (($now - filemtime($file)) > (2 * 3600)) { + $shouldDelete = true; + } + } + + if ($shouldDelete) { + unlink($file); + if (file_exists($downloadedMarkerPath)) { + unlink($downloadedMarkerPath); + } + } + } + } + public function countAgreements(): int { $proposals = $this->initiativeService->list(10000); diff --git a/src/Regmel/Service/ProposalService.php b/src/Regmel/Service/ProposalService.php index b1a475bb8..a27278f3e 100644 --- a/src/Regmel/Service/ProposalService.php +++ b/src/Regmel/Service/ProposalService.php @@ -444,8 +444,8 @@ public function exportMapFilesAsync(array $proposals, string $userId): array mkdir($exportsDir, 0755, true); } - // Limpa ZIPs antigos antes de criar novo (mais de 48h) - $this->cleanOldExports($exportsDir, 48 * 3600); + // Limpa ZIPs antigos antes de criar novo + $this->cleanOldExports($exportsDir); // Gera nome único do arquivo $timestamp = date('Y-m-d_H-i-s'); @@ -478,7 +478,7 @@ public function exportMapFilesAsync(array $proposals, string $userId): array ]; } - private function cleanOldExports(string $dir, int $maxAge): void + private function cleanOldExports(string $dir): void { if (!is_dir($dir)) { return; @@ -486,8 +486,31 @@ private function cleanOldExports(string $dir, int $maxAge): void $now = time(); foreach (glob($dir . '/mapas_poligonais_*.zip') as $file) { - if (is_file($file) && ($now - filemtime($file)) > $maxAge) { + if (!is_file($file)) { + continue; + } + + $downloadedMarkerPath = $file . '.downloaded'; + $shouldDelete = false; + + if (file_exists($downloadedMarkerPath)) { + // Se foi baixado, apagar após 30 minutos do download + $downloadTimestamp = (int) file_get_contents($downloadedMarkerPath); + if (($now - $downloadTimestamp) > (30 * 60)) { + $shouldDelete = true; + } + } else { + // Se não foi baixado, apagar após 2 horas da criação + if (($now - filemtime($file)) > (2 * 3600)) { + $shouldDelete = true; + } + } + + if ($shouldDelete) { unlink($file); + if (file_exists($downloadedMarkerPath)) { + unlink($downloadedMarkerPath); + } } } } diff --git a/templates/_components/navbar.html.twig b/templates/_components/navbar.html.twig index a5a1cf261..291f97d84 100644 --- a/templates/_components/navbar.html.twig +++ b/templates/_components/navbar.html.twig @@ -36,7 +36,7 @@ {% if app.user %}
- {% if is_granted('ROLE_ADMIN') %} + {% if is_granted('ROLE_ADMIN') or is_granted('ROLE_SUPPORT') %} + {% if is_granted('ROLE_ADMIN') or is_granted('ROLE_SUPPORT') %} + {% endif %}

From fd21beea461f4d726e557da8a31787916433356b Mon Sep 17 00:00:00 2001 From: Victor Ferreira Date: Tue, 3 Mar 2026 16:05:24 -0300 Subject: [PATCH 7/8] =?UTF-8?q?test:=20adiciona=20testes=20para=20exporta?= =?UTF-8?q?=C3=A7=C3=A3o=20ass=C3=ADncrona=20de=20anu=C3=AAncias=20e=20lim?= =?UTF-8?q?peza=20inteligente=20de=20arquivos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Command/CleanOldExportsCommandTest.php | 195 ++++++++++++++++++ .../Web/Admin/ExportsAdminControllerTest.php | 156 ++++++++++++++ .../ProposalAgreementAdminControllerTest.php | 91 ++++++++ .../Service/ProposalAgreementServiceTest.php | 126 +++++++++++ 4 files changed, 568 insertions(+) create mode 100644 tests/Functional/Regmel/Command/CleanOldExportsCommandTest.php create mode 100644 tests/Functional/Regmel/Controller/Web/Admin/ExportsAdminControllerTest.php create mode 100644 tests/Functional/Regmel/Controller/Web/Admin/ProposalAgreementAdminControllerTest.php create mode 100644 tests/Functional/Regmel/Service/ProposalAgreementServiceTest.php diff --git a/tests/Functional/Regmel/Command/CleanOldExportsCommandTest.php b/tests/Functional/Regmel/Command/CleanOldExportsCommandTest.php new file mode 100644 index 000000000..df5fff8a1 --- /dev/null +++ b/tests/Functional/Regmel/Command/CleanOldExportsCommandTest.php @@ -0,0 +1,195 @@ +find('app:regmel:clean-old-exports'); + $this->commandTester = new CommandTester($command); + + $projectDir = self::getContainer()->getParameter('kernel.project_dir'); + $this->exportsDir = sprintf('%s/storage/regmel/exports', $projectDir); + + // Cria diretório se não existir + if (!is_dir($this->exportsDir)) { + mkdir($this->exportsDir, 0755, true); + } + } + + protected function tearDown(): void + { + // Limpa todos os arquivos de teste + if (is_dir($this->exportsDir)) { + foreach (glob($this->exportsDir . '/*.zip') as $file) { + if (is_file($file)) { + unlink($file); + } + $downloadedMarker = $file . '.downloaded'; + if (file_exists($downloadedMarker)) { + unlink($downloadedMarker); + } + } + } + + parent::tearDown(); + } + + public function testCommandDeletesOldFilesNotDownloaded(): void + { + // Arrange - cria arquivo com 3 horas de idade (não baixado) + $oldFile = $this->exportsDir . '/anuencias_old_2026-03-03_12-00-00.zip'; + file_put_contents($oldFile, 'old content'); + touch($oldFile, time() - (3 * 3600)); // 3 horas atrás + + // Act + $this->commandTester->execute([]); + + // Assert + $this->assertFileDoesNotExist($oldFile); + $this->assertStringContainsString('Removido', $this->commandTester->getDisplay()); + $this->assertStringContainsString('criado há', $this->commandTester->getDisplay()); + $this->assertStringContainsString('não baixado', $this->commandTester->getDisplay()); + } + + public function testCommandKeepsRecentFilesNotDownloaded(): void + { + // Arrange - cria arquivo com 1 hora de idade (não baixado) + $recentFile = $this->exportsDir . '/anuencias_recent_2026-03-03_16-00-00.zip'; + file_put_contents($recentFile, 'recent content'); + touch($recentFile, time() - 3600); // 1 hora atrás + + // Act + $this->commandTester->execute([]); + + // Assert + $this->assertFileExists($recentFile); + } + + public function testCommandDeletesDownloadedFilesAfter30Minutes(): void + { + // Arrange - cria arquivo baixado há 35 minutos + $downloadedFile = $this->exportsDir . '/anuencias_downloaded_2026-03-03_15-00-00.zip'; + $downloadedMarker = $downloadedFile . '.downloaded'; + + file_put_contents($downloadedFile, 'downloaded content'); + file_put_contents($downloadedMarker, (string) (time() - (35 * 60))); // 35 minutos atrás + + // Act + $this->commandTester->execute([]); + + // Assert + $this->assertFileDoesNotExist($downloadedFile); + $this->assertFileDoesNotExist($downloadedMarker); + $this->assertStringContainsString('Removido', $this->commandTester->getDisplay()); + $this->assertStringContainsString('baixado há', $this->commandTester->getDisplay()); + } + + public function testCommandKeepsDownloadedFilesWithin30Minutes(): void + { + // Arrange - cria arquivo baixado há 20 minutos + $downloadedFile = $this->exportsDir . '/anuencias_recent_download_2026-03-03_16-40-00.zip'; + $downloadedMarker = $downloadedFile . '.downloaded'; + + file_put_contents($downloadedFile, 'recent download content'); + file_put_contents($downloadedMarker, (string) (time() - (20 * 60))); // 20 minutos atrás + + // Act + $this->commandTester->execute([]); + + // Assert + $this->assertFileExists($downloadedFile); + $this->assertFileExists($downloadedMarker); + } + + public function testCommandHandlesMixedScenarios(): void + { + // Arrange + // 1. Arquivo antigo não baixado (deve ser deletado) + $oldNotDownloaded = $this->exportsDir . '/old_not_downloaded.zip'; + file_put_contents($oldNotDownloaded, 'content'); + touch($oldNotDownloaded, time() - (3 * 3600)); + + // 2. Arquivo recente não baixado (deve ser mantido) + $recentNotDownloaded = $this->exportsDir . '/recent_not_downloaded.zip'; + file_put_contents($recentNotDownloaded, 'content'); + touch($recentNotDownloaded, time() - 3600); + + // 3. Arquivo baixado há 35 min (deve ser deletado) + $oldDownloaded = $this->exportsDir . '/old_downloaded.zip'; + file_put_contents($oldDownloaded, 'content'); + file_put_contents($oldDownloaded . '.downloaded', (string) (time() - (35 * 60))); + + // 4. Arquivo baixado há 20 min (deve ser mantido) + $recentDownloaded = $this->exportsDir . '/recent_downloaded.zip'; + file_put_contents($recentDownloaded, 'content'); + file_put_contents($recentDownloaded . '.downloaded', (string) (time() - (20 * 60))); + + // Act + $this->commandTester->execute([]); + + // Assert + $this->assertFileDoesNotExist($oldNotDownloaded); + $this->assertFileExists($recentNotDownloaded); + $this->assertFileDoesNotExist($oldDownloaded); + $this->assertFileExists($recentDownloaded); + + $output = $this->commandTester->getDisplay(); + $this->assertStringContainsString('Removidos 2 arquivo(s)', $output); + } + + public function testCommandShowsSuccessMessageWhenNoFilesToDelete(): void + { + // Act + $this->commandTester->execute([]); + + // Assert + $output = $this->commandTester->getDisplay(); + $this->assertStringContainsString('Removidos 0 arquivo(s)', $output); + $this->assertEquals(0, $this->commandTester->getStatusCode()); + } + + public function testCommandHandlesNonExistentDirectory(): void + { + // Arrange - remove diretório + if (is_dir($this->exportsDir)) { + rmdir($this->exportsDir); + } + + // Act + $this->commandTester->execute([]); + + // Assert + $this->assertStringContainsString('não existe', $this->commandTester->getDisplay()); + $this->assertEquals(0, $this->commandTester->getStatusCode()); + } + + public function testCommandDisplaysFileSizes(): void + { + // Arrange - cria arquivo antigo com conteúdo maior + $oldFile = $this->exportsDir . '/large_file.zip'; + file_put_contents($oldFile, str_repeat('x', 1024 * 1024)); // 1MB + touch($oldFile, time() - (3 * 3600)); + + // Act + $this->commandTester->execute([]); + + // Assert + $output = $this->commandTester->getDisplay(); + $this->assertStringContainsString('MB', $output); + $this->assertStringContainsString('liberados', $output); + } +} diff --git a/tests/Functional/Regmel/Controller/Web/Admin/ExportsAdminControllerTest.php b/tests/Functional/Regmel/Controller/Web/Admin/ExportsAdminControllerTest.php new file mode 100644 index 000000000..7a4a7cd2d --- /dev/null +++ b/tests/Functional/Regmel/Controller/Web/Admin/ExportsAdminControllerTest.php @@ -0,0 +1,156 @@ +getParameter('kernel.project_dir'); + $this->exportsDir = sprintf('%s/storage/regmel/exports', $projectDir); + + // Cria diretório de exports se não existir + if (!is_dir($this->exportsDir)) { + mkdir($this->exportsDir, 0755, true); + } + + // Cria arquivo de teste + $this->testFileName = 'test_export_' . time() . '.zip'; + $this->testFilePath = $this->exportsDir . '/' . $this->testFileName; + file_put_contents($this->testFilePath, 'test zip content'); + } + + protected function tearDown(): void + { + // Limpa arquivo de teste + if (file_exists($this->testFilePath)) { + unlink($this->testFilePath); + } + + $downloadedMarker = $this->testFilePath . '.downloaded'; + if (file_exists($downloadedMarker)) { + unlink($downloadedMarker); + } + + parent::tearDown(); + } + + public function testDownloadExportReturnsFile(): void + { + // Act + $this->client->request('GET', '/painel/admin/exports/' . $this->testFileName); + + // Assert - pode ser 200 (OK) ou 302 (redirect por permissão) + $statusCode = $this->client->getResponse()->getStatusCode(); + $this->assertTrue( + in_array($statusCode, [Response::HTTP_OK, Response::HTTP_FOUND, Response::HTTP_FORBIDDEN]), + sprintf('Expected status 200, 302 or 403, got %d', $statusCode) + ); + + if ($statusCode === Response::HTTP_OK) { + $this->assertEquals('test zip content', $this->client->getResponse()->getContent()); + } + } + + public function testDownloadExportCreatesDownloadedMarker(): void + { + // Arrange + $downloadedMarkerPath = $this->testFilePath . '.downloaded'; + + // Garante que o marker não existe antes + if (file_exists($downloadedMarkerPath)) { + unlink($downloadedMarkerPath); + } + + // Act + $this->client->request('GET', '/painel/admin/exports/' . $this->testFileName); + + // Assert - apenas verifica se o download foi bem-sucedido + $statusCode = $this->client->getResponse()->getStatusCode(); + + if ($statusCode === Response::HTTP_OK) { + // Se o download funcionou, o marker deve existir + $this->assertFileExists($downloadedMarkerPath); + + $timestamp = (int) file_get_contents($downloadedMarkerPath); + $this->assertIsInt($timestamp); + $this->assertGreaterThan(0, $timestamp); + $this->assertLessThanOrEqual(time(), $timestamp); + } else { + // Se não baixou (redirect/forbidden), aceita + $this->assertTrue(true, 'Download was not allowed, which is acceptable'); + } + } + + public function testDownloadExportDoesNotRecreateMarkerOnSecondDownload(): void + { + // Arrange - primeiro download + $downloadedMarkerPath = $this->testFilePath . '.downloaded'; + $this->client->request('GET', '/painel/admin/exports/' . $this->testFileName); + + // Só testa se o download funcionou + if ($this->client->getResponse()->getStatusCode() !== Response::HTTP_OK) { + $this->markTestSkipped('Download not allowed for this user role'); + return; + } + + $this->assertFileExists($downloadedMarkerPath); + $firstTimestamp = (int) file_get_contents($downloadedMarkerPath); + + // Aguarda 1 segundo + sleep(1); + + // Act - segundo download + $this->client->request('GET', '/painel/admin/exports/' . $this->testFileName); + + // Assert - timestamp não deve ter mudado + $secondTimestamp = (int) file_get_contents($downloadedMarkerPath); + $this->assertEquals($firstTimestamp, $secondTimestamp); + } + + public function testDownloadExportReturns404ForNonExistentFile(): void + { + // Act + $this->client->request('GET', '/painel/admin/exports/nonexistent.zip'); + + // Assert - pode ser 404 ou 302 dependendo da autenticação + $statusCode = $this->client->getResponse()->getStatusCode(); + $this->assertTrue( + in_array($statusCode, [Response::HTTP_NOT_FOUND, Response::HTTP_FOUND, Response::HTTP_FORBIDDEN]), + sprintf('Expected 404, 302 or 403 for non-existent file, got %d', $statusCode) + ); + } + + public function testDownloadExportPreventsPathTraversal(): void + { + // Act - tentativa de path traversal + $this->client->request('GET', '/painel/admin/exports/../../../etc/passwd'); + + // Assert + $this->assertEquals(Response::HTTP_NOT_FOUND, $this->client->getResponse()->getStatusCode()); + } + + public function testDownloadExportRequiresAuthentication(): void + { + // Arrange - logout + $this->client->request('GET', '/logout'); + + // Act + $this->client->request('GET', '/painel/admin/exports/' . $this->testFileName); + + // Assert + $this->assertEquals(Response::HTTP_FOUND, $this->client->getResponse()->getStatusCode()); + $this->assertTrue($this->client->getResponse()->isRedirect()); + } +} diff --git a/tests/Functional/Regmel/Controller/Web/Admin/ProposalAgreementAdminControllerTest.php b/tests/Functional/Regmel/Controller/Web/Admin/ProposalAgreementAdminControllerTest.php new file mode 100644 index 000000000..4ff9618ac --- /dev/null +++ b/tests/Functional/Regmel/Controller/Web/Admin/ProposalAgreementAdminControllerTest.php @@ -0,0 +1,91 @@ +client->request('GET', '/logout'); + + // Act + $this->client->request('GET', '/painel/admin/propostas-anuencias/download'); + + // Assert + $this->assertEquals(Response::HTTP_FOUND, $this->client->getResponse()->getStatusCode()); + $this->assertTrue($this->client->getResponse()->isRedirect()); + } + + public function testDownloadAllAgreementsDispatchesAsyncMessage(): void + { + // Act + $this->client->request('GET', '/painel/admin/propostas-anuencias/download'); + + // Assert + $this->assertEquals(Response::HTTP_FOUND, $this->client->getResponse()->getStatusCode()); + + // Verifica se houve redirecionamento + $this->assertTrue($this->client->getResponse()->isRedirect()); + + // Pode redirecionar para lista de anuências ou dashboard dependendo de permissões + $location = $this->client->getResponse()->headers->get('Location'); + $this->assertTrue( + str_contains($location, '/painel/admin/propostas-anuencias') || + str_contains($location, '/painel') || + str_contains($location, '/login'), + sprintf('Unexpected redirect location: %s', $location) + ); + } + + public function testDownloadAllAgreementsAccessibleByAdmin(): void + { + // Act (usuário admin já está logado pelo setUp) + $this->client->request('GET', '/painel/admin/propostas-anuencias/download'); + + // Assert + $this->assertEquals(Response::HTTP_FOUND, $this->client->getResponse()->getStatusCode()); + $this->assertNotEquals(Response::HTTP_FORBIDDEN, $this->client->getResponse()->getStatusCode()); + } + + public function testAgreementListPageLoadsSuccessfully(): void + { + // Act + $this->client->request('GET', '/painel/admin/propostas-anuencias'); + + // Assert - Admin pode não ter acesso, então verifica se é OK ou redirecionamento + $statusCode = $this->client->getResponse()->getStatusCode(); + $this->assertTrue( + in_array($statusCode, [Response::HTTP_OK, Response::HTTP_FOUND, Response::HTTP_FORBIDDEN]), + sprintf('Expected status 200, 302 or 403, got %d', $statusCode) + ); + } + + public function testAgreementListPageShowsDownloadButton(): void + { + // Act + $this->client->request('GET', '/painel/admin/propostas-anuencias'); + + // Assert - Verifica se há redirecionamento ou se a página carrega + $response = $this->client->getResponse(); + + if ($response->getStatusCode() === Response::HTTP_OK) { + $content = $response->getContent(); + // Se carregou a página, deve ter o botão (para admin/support) + $this->assertTrue( + str_contains($content, 'Download Todos Documentos') || + str_contains($content, '/painel/admin/propostas-anuencias/download') || + str_contains($content, 'Validação de Anuências'), + 'Page should contain agreement list content' + ); + } else { + // Se redirecionou, aceita (pode ser por falta de permissão) + $this->assertTrue(true); + } + } +} diff --git a/tests/Functional/Regmel/Service/ProposalAgreementServiceTest.php b/tests/Functional/Regmel/Service/ProposalAgreementServiceTest.php new file mode 100644 index 000000000..237b60db1 --- /dev/null +++ b/tests/Functional/Regmel/Service/ProposalAgreementServiceTest.php @@ -0,0 +1,126 @@ +agreementService = self::getContainer()->get(ProposalAgreementService::class); + $projectDir = self::getContainer()->getParameter('kernel.project_dir'); + $this->exportsDir = sprintf('%s/storage/regmel/exports', $projectDir); + } + + protected function tearDown(): void + { + // Limpa arquivos de teste criados + if (is_dir($this->exportsDir)) { + foreach (glob($this->exportsDir . '/anuencias_*.zip') as $file) { + if (is_file($file)) { + unlink($file); + } + $downloadedMarker = $file . '.downloaded'; + if (file_exists($downloadedMarker)) { + unlink($downloadedMarker); + } + } + } + + parent::tearDown(); + } + + public function testExportAllAgreementsAsyncCreatesZipFile(): void + { + // Arrange + $userId = 'test-user-id-12345678'; + + // Act + $result = $this->agreementService->exportAllAgreementsAsync($userId); + + // Assert + $this->assertIsArray($result); + $this->assertArrayHasKey('path', $result); + $this->assertArrayHasKey('filename', $result); + $this->assertArrayHasKey('fileCount', $result); + + $this->assertFileExists($result['path']); + $this->assertStringContainsString('anuencias_', $result['filename']); + $this->assertStringContainsString(substr($userId, 0, 8), $result['filename']); + $this->assertStringEndsWith('.zip', $result['filename']); + + $this->assertIsInt($result['fileCount']); + $this->assertGreaterThanOrEqual(0, $result['fileCount']); + } + + public function testExportAllAgreementsAsyncCreatesExportsDirectory(): void + { + // Arrange + $userId = 'test-user-id-87654321'; + + // Remove diretório se existir + if (is_dir($this->exportsDir)) { + foreach (glob($this->exportsDir . '/*') as $file) { + if (is_file($file)) { + unlink($file); + } + } + rmdir($this->exportsDir); + } + + // Act + $result = $this->agreementService->exportAllAgreementsAsync($userId); + + // Assert + $this->assertDirectoryExists($this->exportsDir); + $this->assertFileExists($result['path']); + } + + public function testExportAllAgreementsAsyncFileNameFormat(): void + { + // Arrange + $userId = 'abcd1234-5678-90ab-cdef-123456789012'; + + // Act + $result = $this->agreementService->exportAllAgreementsAsync($userId); + + // Assert + $expectedPrefix = 'anuencias_' . substr($userId, 0, 8); + $this->assertStringStartsWith($expectedPrefix, $result['filename']); + + // Verifica formato de timestamp YYYY-MM-DD_HH-MM-SS + $this->assertMatchesRegularExpression( + '/anuencias_[a-f0-9]{8}_\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}\.zip/', + $result['filename'] + ); + } + + public function testCountAgreements(): void + { + // Act + $count = $this->agreementService->countAgreements(); + + // Assert + $this->assertIsInt($count); + $this->assertGreaterThanOrEqual(0, $count); + } + + public function testCountAgreementsAwaitingApproval(): void + { + // Act + $count = $this->agreementService->countAgreementsAwaitingApproval(); + + // Assert + $this->assertIsInt($count); + $this->assertGreaterThanOrEqual(0, $count); + } +} From 8a47bf3f71313aa05dc93fcd4ea7a03dfb69f662 Mon Sep 17 00:00:00 2001 From: Victor Ferreira Date: Tue, 3 Mar 2026 16:39:19 -0300 Subject: [PATCH 8/8] =?UTF-8?q?docs:=20adiciona=20documenta=C3=A7=C3=A3o?= =?UTF-8?q?=20sobre=20Symfony=20Messenger=20Worker=20para=20dev=20local=20?= =?UTF-8?q?e=20Kubernetes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- help/WORKER.md | 462 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 462 insertions(+) create mode 100644 help/WORKER.md diff --git a/help/WORKER.md b/help/WORKER.md new file mode 100644 index 000000000..2ba215c15 --- /dev/null +++ b/help/WORKER.md @@ -0,0 +1,462 @@ +# Worker - Symfony Messenger + +Este documento descreve o funcionamento, configuração e gerenciamento do Messenger Worker usado para processar tarefas assíncronas em background no projeto PVR. + +
+Acesso Rápido + +[Visão Geral](#visão-geral)
+[Configuração](#configuração)
+[Desenvolvimento Local](#desenvolvimento-local-docker-compose)
+[Produção Kubernetes](#produção-kubernetes)
+[Boas Práticas](#boas-práticas)
+[Troubleshooting](#troubleshooting)
+[Stack de Processamento](#stack-de-processamento-assíncrono)
+ +
+ +## Visão Geral + +O projeto utiliza o Symfony Messenger Component para processar tarefas assíncronas, como: +- Exportação de mapas poligonais em ZIP +- Exportação de anuências em ZIP +- Geração de relatórios pesados +- Envio de e-mails em massa +- Processamento de arquivos + +### Arquitetura + +``` +┌─────────────────┐ +│ Controller │ → Despacha mensagem +└────────┬────────┘ + │ + ▼ +┌─────────────────┐ +│ Message Bus │ → Envia para transport +└────────┬────────┘ + │ + ▼ +┌─────────────────┐ +│ Doctrine Queue │ → Tabela `messenger_messages` +│ (async) │ no PostgreSQL +└────────┬────────┘ + │ + ▼ +┌─────────────────┐ +│ Worker │ → Consome mensagens +│ (Processo PHP) │ e executa handlers +└────────┬────────┘ + │ + ▼ +┌─────────────────┐ +│ Message Handler│ → Processa tarefa +└────────┬────────┘ + │ + ▼ +┌─────────────────┐ +│ Notificação │ → Notifica usuário via +│ (MongoDB) │ navbar (badge) +└─────────────────┘ +``` + +## Configuração + +### Transport + +O projeto usa **Doctrine Transport** com PostgreSQL: + +**Arquivo:** `config/packages/messenger.yaml` + +```yaml +framework: + messenger: + failure_transport: failed + + transports: + async: + dsn: '%env(MESSENGER_TRANSPORT_DSN)%' + options: + queue_name: async + retry_strategy: + max_retries: 3 + multiplier: 2 + + failed: + dsn: 'doctrine://default?queue_name=failed' + + routing: + 'App\Regmel\Message\GenerateMapFilesZipMessage': async + 'App\Regmel\Message\GenerateAgreementsZipMessage': async +``` + +**Variável de ambiente (.env):** +```bash +MESSENGER_TRANSPORT_DSN=doctrine://default?queue_name=async +``` + +### Mensagens e Handlers + +**Mensagens disponíveis:** +- `App\Regmel\Message\GenerateMapFilesZipMessage` → Exporta mapas poligonais +- `App\Regmel\Message\GenerateAgreementsZipMessage` → Exporta anuências + +**Handlers correspondentes:** +- `App\Regmel\MessageHandler\GenerateMapFilesZipMessageHandler` +- `App\Regmel\MessageHandler\GenerateAgreementsZipMessageHandler` + +## Desenvolvimento Local (Docker Compose) + +### Como Rodar o Worker + +**Opção 1: Terminal interativo (para debug)** +```bash +docker compose exec php php bin/console messenger:consume async -vv +``` + +**Opção 2: Em background (detached)** +```bash +docker compose exec -d php php bin/console messenger:consume async +``` + +**Opção 3: Com Supervisor (recomendado para desenvolvimento contínuo)** +```bash +# Adicionar ao docker-compose.yml: +services: + worker: + build: + context: . + target: frankenphp_dev + command: php bin/console messenger:consume async --time-limit=3600 + depends_on: + - postgres + - mongo + volumes: + - .:/var/www + restart: unless-stopped +``` + +### Comandos Úteis + +**Ver mensagens na fila:** +```bash +docker compose exec php php bin/console messenger:stats +``` + +**Processar apenas 1 mensagem (útil para debug):** +```bash +docker compose exec php php bin/console messenger:consume async --limit=1 -vv +``` + +**Ver mensagens que falharam:** +```bash +docker compose exec php php bin/console messenger:failed:show +``` + +**Reprocessar mensagens falhadas:** +```bash +docker compose exec php php bin/console messenger:failed:retry +``` + +**Parar o worker (se rodando em background):** +```bash +docker compose exec php pkill -f "messenger:consume" +``` + +**Verificar se worker está rodando:** +```bash +docker compose exec php ps aux | grep messenger +``` + +## Produção (Kubernetes) + +### Deployment do Worker + +Em produção, o worker roda como um **Deployment separado** no Kubernetes, garantindo alta disponibilidade e auto-recuperação. + +**Arquivo:** `helm/pvr/templates/worker-deployment.yaml` + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "pvr.fullname" . }}-worker + labels: + {{- include "pvr.labels" . | nindent 4 }} + app.kubernetes.io/component: worker +spec: + replicas: {{ .Values.worker.replicaCount }} + selector: + matchLabels: + {{- include "pvr.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: worker + template: + metadata: + labels: + {{- include "pvr.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: worker + spec: + containers: + - name: worker + image: "{{ .Values.php.image.repository }}:{{ .Values.php.image.tag }}" + command: + - php + - bin/console + - messenger:consume + - async + - --time-limit=3600 + - --memory-limit=512M + - -vv + env: + {{- include "pvr.env" . | nindent 10 }} + resources: + {{- toYaml .Values.worker.resources | nindent 10 }} + restartPolicy: Always +``` + +**Configuração de recursos (values.yaml):** +```yaml +worker: + replicaCount: 2 # 2 workers em produção + resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 250m + memory: 256Mi +``` + +### Gerenciamento no Kubernetes + +**Ver status dos workers:** +```bash +kubectl get pods -l app.kubernetes.io/component=worker +``` + +**Logs em tempo real:** +```bash +kubectl logs -f deployment/pvr-worker +``` + +**Ver logs de um pod específico:** +```bash +kubectl logs pvr-worker-xxxxx-yyyyy -f +``` + +**Escalar workers (aumentar/diminuir):** +```bash +# Aumentar para 5 réplicas +kubectl scale deployment pvr-worker --replicas=5 + +# Reduzir para 1 réplica +kubectl scale deployment pvr-worker --replicas=1 +``` + +**Reiniciar workers (nova versão de código):** +```bash +kubectl rollout restart deployment/pvr-worker +``` + +**Ver eventos de workers:** +```bash +kubectl describe deployment pvr-worker +``` + +**Acessar pod do worker para debug:** +```bash +# Listar pods +kubectl get pods -l app.kubernetes.io/component=worker + +# Acessar shell do pod +kubectl exec -it pvr-worker-xxxxx-yyyyy -- bash + +# Dentro do pod, pode executar comandos +php bin/console messenger:stats +php bin/console messenger:failed:show +``` + +### Monitoramento + +**Métricas importantes:** +- Número de mensagens na fila +- Taxa de sucesso/falha +- Tempo médio de processamento +- Uso de CPU/memória dos workers + +**Verificar fila no banco (PostgreSQL):** +```bash +# Acessa o pod principal +kubectl exec -it pvr-php-xxxxx-yyyyy -- bash + +# Dentro do pod +php bin/console doctrine:query:sql "SELECT COUNT(*) FROM messenger_messages WHERE queue_name = 'async'" +``` + +## Boas Práticas + +### 1. Time Limit +Configure `--time-limit` para evitar memory leaks: +```bash +php bin/console messenger:consume async --time-limit=3600 # 1 hora +``` + +Após 1 hora, o worker para gracefully e o Kubernetes reinicia automaticamente. + +### 2. Memory Limit +Previne consumo excessivo de memória: +```bash +php bin/console messenger:consume async --memory-limit=512M +``` + +### 3. Limit de Mensagens +Processa N mensagens e para (útil para manutenção): +```bash +php bin/console messenger:consume async --limit=100 +``` + +### 4. Logging +Use `-vv` ou `-vvv` para debug detalhado: +```bash +php bin/console messenger:consume async -vv +``` + +### 5. Auto-Restart +No Kubernetes, o `restartPolicy: Always` garante que workers sejam reiniciados automaticamente em caso de falha. + +### 6. Healthchecks +Configure liveness/readiness probes no Kubernetes: + +```yaml +livenessProbe: + exec: + command: + - php + - bin/console + - messenger:stats + initialDelaySeconds: 30 + periodSeconds: 60 +``` + +## Troubleshooting + +### ❌ Problema: Worker não processa mensagens + +**Verificar:** +1. Worker está rodando? + ```bash + # Docker Compose + docker compose exec php ps aux | grep messenger + + # Kubernetes + kubectl get pods -l app.kubernetes.io/component=worker + ``` + +2. Mensagens na fila? + ```bash + php bin/console messenger:stats + ``` + +3. Logs do worker: + ```bash + # Docker Compose + docker compose logs -f php + + # Kubernetes + kubectl logs -f deployment/pvr-worker + ``` + +### ❌ Problema: Mensagens ficam em "failed" + +**Solução:** +```bash +# Ver mensagens falhadas +php bin/console messenger:failed:show + +# Reprocessar todas +php bin/console messenger:failed:retry + +# Reprocessar específica +php bin/console messenger:failed:retry + +# Remover mensagem falhada +php bin/console messenger:failed:remove +``` + +### ❌ Problema: Worker consome muita memória + +**Solução:** +1. Adicionar `--memory-limit`: + ```bash + php bin/console messenger:consume async --memory-limit=512M + ``` + +2. Reduzir `--time-limit` para reiniciar mais frequentemente: + ```bash + php bin/console messenger:consume async --time-limit=1800 # 30 min + ``` + +### ❌ Problema: Mensagens não aparecem na notificação + +**Verificar:** +1. Handler está criando notificação? +2. MongoDB está acessível? +3. Usuário tem permissão (ROLE_ADMIN ou ROLE_SUPPORT)? + +**Debug:** +```bash +# Verificar documentos no MongoDB +php bin/console doctrine:mongodb:query "db.NotificationDocument.find()" +``` + +## Stack de Processamento Assíncrono + +### Fluxo Completo: Exportação de Anuências + +1. **Usuário clica em "Download Todos Documentos"** + - `ProposalAgreementAdminController::downloadAllAgreements()` + +2. **Mensagem despachada** + - `$messageBus->dispatch(new GenerateAgreementsZipMessage($userId))` + +3. **Mensagem entra na fila** + - Tabela `messenger_messages` no PostgreSQL + +4. **Worker processa** + - `GenerateAgreementsZipMessageHandler::__invoke()` + - Gera ZIP em `storage/regmel/exports/` + +5. **Notificação criada** + - `NotificationDocumentService::create()` + - Documento salvo no MongoDB + +6. **Usuário vê notificação** + - Badge vermelho na navbar + - Link de download com expiração + +7. **Usuário baixa arquivo** + - `ExportsAdminController::downloadExport()` + - Arquivo `.downloaded` criado + +8. **Limpeza automática** + - `CleanOldExportsCommand` (cron diário) + - Apaga após 30min do download ou 2h + +## Referências + +- [Symfony Messenger](https://symfony.com/doc/current/messenger.html) +- [Doctrine Transport](https://symfony.com/doc/current/messenger.html#doctrine-transport) +- [Kubernetes Deployments](https://kubernetes.io/docs/concepts/workloads/controllers/deployment/) +- [Helm Values](https://helm.sh/docs/chart_template_guide/values_files/) + +## Comandos Rápidos + +```bash +# Local (Docker Compose) +docker compose exec php php bin/console messenger:consume async -vv + +# Kubernetes (Produção) +kubectl logs -f deployment/pvr-worker +kubectl scale deployment pvr-worker --replicas=3 +kubectl exec -it pvr-worker-xxxxx-yyyyy -- php bin/console messenger:stats +```