diff --git a/.editorconfig b/.editorconfig index e49ab5718..beebd2703 100644 --- a/.editorconfig +++ b/.editorconfig @@ -21,3 +21,7 @@ indent_size = 4 [*.yml] indent_style = space indent_size = 4 + +[*.xml] +indent_style = space +indent_size = 4 diff --git a/.env.dist b/.env.dist index 881374f80..015ea10b6 100644 --- a/.env.dist +++ b/.env.dist @@ -45,3 +45,7 @@ APP_SECRET=change_this_to_something_unique #================================================================================ # unlikely, that you need to change this one CORS_ALLOW_ORIGIN=^https?://localhost(:[0-9]+)?$ + +###> symfony/messenger ### +MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0 +###< symfony/messenger ### diff --git a/composer.json b/composer.json index a651e132d..2c1f5998a 100644 --- a/composer.json +++ b/composer.json @@ -74,6 +74,7 @@ "symfony/translation": "^6.0", "symfony/twig-bundle": "^6.0", "symfony/validator": "^6.0", + "symfony/webhook": "^6.0", "symfony/webpack-encore-bundle": "^2.0", "symfony/yaml": "^6.0", "twig/cssinliner-extra": "^3.0", diff --git a/composer.lock b/composer.lock index 02231ce8f..4d27264a8 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": "9f49e3cfd10e3bdd29e6bd67968ec19b", + "content-hash": "9d1ceee2eb5d9ad5d14add5cd65f49d2", "packages": [ { "name": "azuyalabs/yasumi", @@ -7725,6 +7725,97 @@ ], "time": "2025-10-24T13:29:09+00:00" }, + { + "name": "symfony/messenger", + "version": "v6.4.28", + "source": { + "type": "git", + "url": "https://github.com/symfony/messenger.git", + "reference": "6a758210a2392c4a326de84a6c8c70e7e18296f9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/messenger/zipball/6a758210a2392c4a326de84a6c8c70e7e18296f9", + "reference": "6a758210a2392c4a326de84a6c8c70e7e18296f9", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/log": "^1|^2|^3", + "symfony/clock": "^6.3|^7.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/console": "<6.3", + "symfony/event-dispatcher": "<5.4", + "symfony/event-dispatcher-contracts": "<2.5", + "symfony/framework-bundle": "<5.4", + "symfony/http-kernel": "<5.4", + "symfony/serializer": "<5.4" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/console": "^6.3|^7.0", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/event-dispatcher": "^5.4|^6.0|^7.0", + "symfony/http-kernel": "^5.4|^6.0|^7.0", + "symfony/process": "^5.4|^6.0|^7.0", + "symfony/property-access": "^5.4|^6.0|^7.0", + "symfony/rate-limiter": "^5.4|^6.0|^7.0", + "symfony/routing": "^5.4|^6.0|^7.0", + "symfony/serializer": "^5.4|^6.0|^7.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^5.4|^6.0|^7.0", + "symfony/validator": "^5.4|^6.0|^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/v6.4.28" + }, + "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-11-06T11:12:43+00:00" + }, { "name": "symfony/mime", "version": "v6.4.30", @@ -8595,6 +8686,75 @@ ], "time": "2025-07-10T08:14:14+00:00" }, + { + "name": "symfony/remote-event", + "version": "v6.4.24", + "source": { + "type": "git", + "url": "https://github.com/symfony/remote-event.git", + "reference": "920e68c4f09f460609cf39a3540f1aa19da72c21" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/remote-event/zipball/920e68c4f09f460609cf39a3540f1aa19da72c21", + "reference": "920e68c4f09f460609cf39a3540f1aa19da72c21", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/messenger": "^5.4|^6.1|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\RemoteEvent\\": "" + }, + "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": "Eases handling remote events", + "homepage": "https://symfony.com", + "keywords": [ + "event" + ], + "support": { + "source": "https://github.com/symfony/remote-event/tree/v6.4.24" + }, + "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-10T08:14:14+00:00" + }, { "name": "symfony/routing", "version": "v6.4.30", @@ -10131,6 +10291,78 @@ ], "time": "2025-09-11T09:57:09+00:00" }, + { + "name": "symfony/webhook", + "version": "v6.4.24", + "source": { + "type": "git", + "url": "https://github.com/symfony/webhook.git", + "reference": "95d6f437a17224029983f2bed3cfaca09e530a17" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/webhook/zipball/95d6f437a17224029983f2bed3cfaca09e530a17", + "reference": "95d6f437a17224029983f2bed3cfaca09e530a17", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/http-foundation": "^6.3|^7.0", + "symfony/http-kernel": "^6.3|^7.0", + "symfony/messenger": "^5.4|^6.1|^7.0", + "symfony/remote-event": "^6.3|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Webhook\\": "" + }, + "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": "Eases sending and consuming webhooks", + "homepage": "https://symfony.com", + "keywords": [ + "webhook" + ], + "support": { + "source": "https://github.com/symfony/webhook/tree/v6.4.24" + }, + "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-10T08:14:14+00:00" + }, { "name": "symfony/webpack-encore-bundle", "version": "v2.4.0", diff --git a/config/packages/messenger.yaml b/config/packages/messenger.yaml new file mode 100644 index 000000000..19db483da --- /dev/null +++ b/config/packages/messenger.yaml @@ -0,0 +1,22 @@ +framework: + messenger: + # Uncomment this (and the failed transport below) to send failed messages to this transport for later handling. + # failure_transport: failed + + transports: + # https://symfony.com/doc/current/messenger.html#transport-configuration + # async: '%env(MESSENGER_TRANSPORT_DSN)%' + # failed: 'doctrine://default?queue_name=failed' + sync: 'sync://' + + routing: + # Route your messages to the transports + # 'App\Message\YourMessage': async + +# when@test: +# framework: +# messenger: +# transports: +# # replace with your transport name here (e.g., my_transport: 'in-memory://') +# # For more Messenger testing tools, see https://github.com/zenstruck/messenger-test +# async: 'in-memory://' diff --git a/config/routes/webhook.yaml b/config/routes/webhook.yaml new file mode 100644 index 000000000..aa981e77e --- /dev/null +++ b/config/routes/webhook.yaml @@ -0,0 +1,3 @@ +#webhook: +# resource: '@FrameworkBundle/Resources/config/routing/webhook.xml' +# prefix: /webhook diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 577d748af..6bc79f5f2 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -38,6 +38,13 @@ GRANT execute,select,insert,update,delete,create,alter,drop,index,references ON `kimai2_test`.* TO kimai2_test@127.0.0.1; --> + + + + + + + diff --git a/src/Entity/WebhookConfiguration.php b/src/Entity/WebhookConfiguration.php new file mode 100644 index 000000000..39cb5e45f --- /dev/null +++ b/src/Entity/WebhookConfiguration.php @@ -0,0 +1,60 @@ +name = $name; + $this->url = $url; + $this->transport = $transport; + $this->secret = $secret; + $this->authentication = $authentication; + } + + public function getName(): string + { + return $this->name; + } + + public function getUrl(): string + { + return $this->url; + } + + public function getTransport(): string + { + return $this->transport; + } + + public function getSecret(): string + { + return $this->secret; + } + + public function getAuthentication(): string + { + return $this->authentication; + } +} diff --git a/src/Entity/WebhookEvent.php b/src/Entity/WebhookEvent.php new file mode 100644 index 000000000..7b4ab9618 --- /dev/null +++ b/src/Entity/WebhookEvent.php @@ -0,0 +1,32 @@ +name = $name; + $this->configuration = $configuration; + } + + public function getName(): string + { + return $this->name; + } + + public function getConfiguration(): WebhookConfiguration + { + return $this->configuration; + } +} diff --git a/src/Serializer/Serializer.php b/src/Serializer/Serializer.php new file mode 100644 index 000000000..4a8328735 --- /dev/null +++ b/src/Serializer/Serializer.php @@ -0,0 +1,64 @@ +setGroups($groups); + $jmsContext->enableMaxDepthChecks(); + } + + public function toArray(mixed $data, array $context = []): array + { + $jmsContext = SerializationContext::create(); + $this->prepareContext($jmsContext, $context); + + if ($this->serializer instanceof CoreSerializer) { + return $this->serializer->toArray($data, $jmsContext); + } + + $json = $this->serializer->serialize($data, 'json', $jmsContext); + + return json_decode($json, true, 512, JSON_THROW_ON_ERROR); + } + + public function serialize(mixed $data, string $format, array $context = []): string + { + $jmsContext = SerializationContext::create(); + $this->prepareContext($jmsContext, $context); + + return $this->serializer->serialize($data, $format, $jmsContext); + } + + public function deserialize(mixed $data, string $type, string $format, array $context = []): mixed + { + $jmsContext = DeserializationContext::create(); + $this->prepareContext($jmsContext, $context); + + return $this->serializer->deserialize($data, $type, $format, $jmsContext); + } +} diff --git a/src/Serializer/SerializerInterface.php b/src/Serializer/SerializerInterface.php new file mode 100644 index 000000000..bbd4683ba --- /dev/null +++ b/src/Serializer/SerializerInterface.php @@ -0,0 +1,17 @@ + ['triggerWebhook', 1000], + ActivityDeleteEvent::class => ['triggerWebhook', 1000], + ActivityUpdatePostEvent::class => ['triggerWebhook', 1000], + + CustomerCreatePostEvent::class => ['triggerWebhook', 1000], + CustomerDeleteEvent::class => ['triggerWebhook', 1000], + CustomerUpdatePostEvent::class => ['triggerWebhook', 1000], + + InvoiceCreatedEvent::class => ['triggerWebhook', 1000], + InvoiceDeleteEvent::class => ['triggerWebhook', 1000], + + ProjectCreatePostEvent::class => ['triggerWebhook', 1000], + ProjectDeleteEvent::class => ['triggerWebhook', 1000], + ProjectUpdatePostEvent::class => ['triggerWebhook', 1000], + + TimesheetCreatePostEvent::class => ['triggerWebhook', 1000], + TimesheetStopPostEvent::class => ['triggerWebhook', 1000], + TimesheetUpdatePostEvent::class => ['triggerWebhook', 1000], + + UserCreatePostEvent::class => ['triggerWebhook', 1000], + UserDeletePostEvent::class => ['triggerWebhook', 1000], + UserUpdatePostEvent::class => ['triggerWebhook', 1000], + + TeamCreatePostEvent::class => ['triggerWebhook', 1000], + TeamDeleteEvent::class => ['triggerWebhook', 1000], + TeamUpdatePostEvent::class => ['triggerWebhook', 1000], + ]; + } + + public function triggerWebhook(Event $event): void + { + $attribute = $this->findAttribute($event); + if ($attribute === null) { + return; + } + + if (!$this->webhookService->hasWebhook($attribute->name)) { + return; + } + + $expressionLanguage = new ExpressionLanguage(); + $parsed = $expressionLanguage->parse($attribute->payload, ['object']); + $payload = $parsed->getNodes()->evaluate([], ['object' => $event]); + + $this->webhookService->trigger($attribute->name, $payload); + } + + private function findAttribute(Event $event): ?AsWebhook + { + try { + $reflectionClass = new \ReflectionClass($event); + $attributes = $reflectionClass->getAttributes(AsWebhook::class); + if (\count($attributes) === 1) { + $attribute = $attributes[0]; + $args = $attribute->getArguments(); + + return new AsWebhook($args['name'], $args['description'], $args['payload']); + } + } catch (\Exception $ex) { + } + + return null; + } +} diff --git a/src/Webhook/WebhookService.php b/src/Webhook/WebhookService.php new file mode 100644 index 000000000..4b1a10e23 --- /dev/null +++ b/src/Webhook/WebhookService.php @@ -0,0 +1,58 @@ +serializer->toArray($payload, ['groups' => ['Default', 'Entity', 'Expanded']]); + + foreach ($this->findEventsByName($name) as $event) { + $configuration = $event->getConfiguration(); + $subscriber = new Subscriber($configuration->getUrl(), $configuration->getSecret()); + $remoteEvent = new RemoteEvent($name, $name . '_' . uniqid(microtime()), $content); + $message = new SendWebhookMessage($subscriber, $remoteEvent); + $this->bus->dispatch($message); + } + } + + public function findEventsByName(string $name): array + { + $url = 'TODO'; + $secret = 'TODO'; + + $configuration = new WebhookConfiguration('n8n', $url, 'json', $secret, 'bearer'); + + return [ + new WebhookEvent($name, $configuration), + ]; + } +} diff --git a/symfony.lock b/symfony.lock index 9f35a6aa5..1a46158ae 100644 --- a/symfony.lock +++ b/symfony.lock @@ -467,6 +467,18 @@ "config/packages/mailer.yaml" ] }, + "symfony/messenger": { + "version": "6.4", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "6.0", + "ref": "d8936e2e2230637ef97e5eecc0eea074eecae58b" + }, + "files": [ + "config/packages/messenger.yaml" + ] + }, "symfony/mime": { "version": "v4.3.3" }, @@ -598,6 +610,18 @@ "ref": "6bdfa1a95f6b2e677ab985cd1af2eae35d62e0f6" } }, + "symfony/webhook": { + "version": "6.4", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "6.3", + "ref": "0bc853a74c98e448116ba53e118a7b6b3cdf9a03" + }, + "files": [ + "config/routes/webhook.yaml" + ] + }, "symfony/webpack-encore-bundle": { "version": "1.0", "recipe": {