From 3489ee8c9cd829f1d3f6525f0171c0b8dd4a2158 Mon Sep 17 00:00:00 2001 From: Daniel Krizan Date: Tue, 16 Jun 2026 15:12:46 +0200 Subject: [PATCH 01/16] feat: add CONTENT_DELIVERY_PUBLISH webhook event type and payload --- .../ContentDeliveryPublishWebhookData.kt | 18 ++++++++++++++++++ .../automations/processors/WebhookEventType.kt | 1 + .../automations/processors/WebhookRequest.kt | 1 + 3 files changed, 20 insertions(+) create mode 100644 backend/data/src/main/kotlin/io/tolgee/component/automations/processors/ContentDeliveryPublishWebhookData.kt diff --git a/backend/data/src/main/kotlin/io/tolgee/component/automations/processors/ContentDeliveryPublishWebhookData.kt b/backend/data/src/main/kotlin/io/tolgee/component/automations/processors/ContentDeliveryPublishWebhookData.kt new file mode 100644 index 00000000000..d3a34529187 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/component/automations/processors/ContentDeliveryPublishWebhookData.kt @@ -0,0 +1,18 @@ +package io.tolgee.component.automations.processors + +import io.swagger.v3.oas.annotations.media.Schema + +data class ContentDeliveryPublishWebhookData( + @get:Schema(description = "ID of the project the content delivery config belongs to") + val projectId: Long = 0, + @get:Schema(description = "ID of the content delivery config that was published") + val id: Long = 0, + @get:Schema(description = "Name of the content delivery config") + val name: String = "", + @get:Schema(description = "Slug (storage path prefix) of the content delivery config") + val slug: String = "", + @get:Schema(description = "Epoch millis when the publish completed") + val lastPublished: Long? = null, + @get:Schema(description = "Relative paths of the files that were published") + val files: List = listOf(), +) diff --git a/backend/data/src/main/kotlin/io/tolgee/component/automations/processors/WebhookEventType.kt b/backend/data/src/main/kotlin/io/tolgee/component/automations/processors/WebhookEventType.kt index 8b9353bfe9c..83789d7f374 100644 --- a/backend/data/src/main/kotlin/io/tolgee/component/automations/processors/WebhookEventType.kt +++ b/backend/data/src/main/kotlin/io/tolgee/component/automations/processors/WebhookEventType.kt @@ -3,4 +3,5 @@ package io.tolgee.component.automations.processors enum class WebhookEventType { TEST, PROJECT_ACTIVITY, + CONTENT_DELIVERY_PUBLISH, } diff --git a/backend/data/src/main/kotlin/io/tolgee/component/automations/processors/WebhookRequest.kt b/backend/data/src/main/kotlin/io/tolgee/component/automations/processors/WebhookRequest.kt index 239a1c769d8..59a87a03362 100644 --- a/backend/data/src/main/kotlin/io/tolgee/component/automations/processors/WebhookRequest.kt +++ b/backend/data/src/main/kotlin/io/tolgee/component/automations/processors/WebhookRequest.kt @@ -7,4 +7,5 @@ data class WebhookRequest( val projectId: Long?, val eventType: WebhookEventType, val activityData: IProjectActivityModel?, + val contentDeliveryConfig: ContentDeliveryPublishWebhookData? = null, ) From ec7c625ba95bbc7eeef12cd03fe3660537d95d72 Mon Sep 17 00:00:00 2001 From: Daniel Krizan Date: Tue, 16 Jun 2026 15:35:44 +0200 Subject: [PATCH 02/16] feat: add eventTypes subscription field to webhook config --- .../webhook/WebhookConfigEventTypesTest.kt | 27 +++++++++++++++++ .../io/tolgee/model/webhook/WebhookConfig.kt | 9 ++++++ .../main/resources/db/changelog/schema.xml | 30 +++++++++++++++++++ 3 files changed, 66 insertions(+) create mode 100644 backend/app/src/test/kotlin/io/tolgee/model/webhook/WebhookConfigEventTypesTest.kt diff --git a/backend/app/src/test/kotlin/io/tolgee/model/webhook/WebhookConfigEventTypesTest.kt b/backend/app/src/test/kotlin/io/tolgee/model/webhook/WebhookConfigEventTypesTest.kt new file mode 100644 index 00000000000..fb53db0d493 --- /dev/null +++ b/backend/app/src/test/kotlin/io/tolgee/model/webhook/WebhookConfigEventTypesTest.kt @@ -0,0 +1,27 @@ +package io.tolgee.model.webhook + +import io.tolgee.AbstractSpringTest +import io.tolgee.component.automations.processors.WebhookEventType +import io.tolgee.development.testDataBuilder.data.WebhooksTestData +import io.tolgee.repository.WebhookConfigRepository +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest + +@SpringBootTest +class WebhookConfigEventTypesTest : AbstractSpringTest() { + @Autowired + lateinit var webhookConfigRepository: WebhookConfigRepository + + @Test + fun `persists and reads back event types`() { + val testData = WebhooksTestData() + testData.webhookConfig.self.eventTypes = + mutableSetOf(WebhookEventType.CONTENT_DELIVERY_PUBLISH) + testDataService.saveTestData(testData.root) + + val reloaded = webhookConfigRepository.findById(testData.webhookConfig.self.id).get() + assertThat(reloaded.eventTypes).containsExactly(WebhookEventType.CONTENT_DELIVERY_PUBLISH) + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/model/webhook/WebhookConfig.kt b/backend/data/src/main/kotlin/io/tolgee/model/webhook/WebhookConfig.kt index 14babfabf16..66034f6cb88 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/webhook/WebhookConfig.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/webhook/WebhookConfig.kt @@ -1,12 +1,15 @@ package io.tolgee.model.webhook +import io.hypersistence.utils.hibernate.type.json.JsonBinaryType import io.tolgee.activity.annotation.ActivityDescribingProp import io.tolgee.activity.annotation.ActivityIgnoredProp import io.tolgee.activity.annotation.ActivityLoggedEntity import io.tolgee.activity.annotation.ActivityLoggedProp +import io.tolgee.component.automations.processors.WebhookEventType import io.tolgee.model.Project import io.tolgee.model.StandardAuditModel import io.tolgee.model.automations.AutomationAction +import jakarta.persistence.Column import jakarta.persistence.Entity import jakarta.persistence.FetchType import jakarta.persistence.Index @@ -15,6 +18,7 @@ import jakarta.persistence.OneToMany import jakarta.persistence.Table import jakarta.validation.constraints.NotBlank import java.util.Date +import org.hibernate.annotations.Type @Entity @ActivityLoggedEntity @@ -38,6 +42,11 @@ class WebhookConfig( @ActivityLoggedProp var enabled: Boolean = true + @ActivityLoggedProp + @Type(JsonBinaryType::class) + @Column(columnDefinition = "jsonb") + var eventTypes: MutableSet = mutableSetOf(WebhookEventType.PROJECT_ACTIVITY) + @OneToMany(mappedBy = "webhookConfig", orphanRemoval = true) var automationActions: MutableList = mutableListOf() diff --git a/backend/data/src/main/resources/db/changelog/schema.xml b/backend/data/src/main/resources/db/changelog/schema.xml index 4afede661f2..362af774541 100644 --- a/backend/data/src/main/resources/db/changelog/schema.xml +++ b/backend/data/src/main/resources/db/changelog/schema.xml @@ -5775,4 +5775,34 @@ ]]> + + + Add event_types jsonb column to webhook_config for per-event-type subscription. + + + + + + + Existing webhooks keep their current behaviour: PROJECT_ACTIVITY only. + + UPDATE webhook_config + SET event_types = '["PROJECT_ACTIVITY"]'::jsonb + WHERE event_types IS NULL; + + + + + Default new rows to PROJECT_ACTIVITY at the DB level. + + ALTER TABLE webhook_config + ALTER COLUMN event_types SET DEFAULT '["PROJECT_ACTIVITY"]'::jsonb; + + + + + event_types must always be present. + + From 0072e6e138eae3ff128f500dca33875c962ab5db Mon Sep 17 00:00:00 2001 From: Daniel Krizan Date: Tue, 16 Jun 2026 15:40:26 +0200 Subject: [PATCH 03/16] feat: expose eventTypes in webhook config API --- .../hateoas/ee/webhooks/WebhookConfigModel.kt | 3 +++ .../dtos/request/WebhookConfigRequest.kt | 3 +++ .../assemblers/WebhookConfigModelAssembler.kt | 1 + .../tolgee/ee/service/WebhookConfigService.kt | 3 +++ .../WebhookConfigControllerTest.kt | 25 +++++++++++++++++++ 5 files changed, 35 insertions(+) diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/ee/webhooks/WebhookConfigModel.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/ee/webhooks/WebhookConfigModel.kt index 66cda0aed3d..19ccf288847 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/ee/webhooks/WebhookConfigModel.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/ee/webhooks/WebhookConfigModel.kt @@ -1,6 +1,7 @@ package io.tolgee.hateoas.ee.webhooks import io.swagger.v3.oas.annotations.media.Schema +import io.tolgee.component.automations.processors.WebhookEventType import org.springframework.hateoas.RepresentationModel import org.springframework.hateoas.server.core.Relation import java.io.Serializable @@ -29,5 +30,7 @@ class WebhookConfigModel( description = """Date of the last webhook request.""", ) var lastExecuted: Long?, + @Schema(description = "Event types this webhook is subscribed to.") + val eventTypes: Set, ) : RepresentationModel(), Serializable diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/request/WebhookConfigRequest.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/request/WebhookConfigRequest.kt index 666638c9b09..ed99656b6c4 100644 --- a/backend/data/src/main/kotlin/io/tolgee/dtos/request/WebhookConfigRequest.kt +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/request/WebhookConfigRequest.kt @@ -1,9 +1,12 @@ package io.tolgee.dtos.request +import io.tolgee.component.automations.processors.WebhookEventType import jakarta.validation.constraints.Size data class WebhookConfigRequest( @field:Size(max = 255) var url: String = "", var enabled: Boolean? = null, + @field:Size(min = 1, message = "At least one event type must be selected") + var eventTypes: Set? = null, ) diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/hateoas/assemblers/WebhookConfigModelAssembler.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/hateoas/assemblers/WebhookConfigModelAssembler.kt index 26ab1c255d7..fc5aebaa606 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/hateoas/assemblers/WebhookConfigModelAssembler.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/hateoas/assemblers/WebhookConfigModelAssembler.kt @@ -21,6 +21,7 @@ class WebhookConfigModelAssembler : autoDisabled = entity.autoDisabled, firstFailed = entity.firstFailed?.time, lastExecuted = entity.lastExecuted?.time, + eventTypes = entity.eventTypes, ) } } diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/WebhookConfigService.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/WebhookConfigService.kt index 2e5851a5128..0823702bb3c 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/WebhookConfigService.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/WebhookConfigService.kt @@ -51,6 +51,8 @@ class WebhookConfigService( urlSecurity.validateUrl(dto.url, webhookProperties.allowLocalAddresses) val webhookConfig = WebhookConfig(project) webhookConfig.url = dto.url + webhookConfig.eventTypes = + dto.eventTypes?.toMutableSet() ?: mutableSetOf(WebhookEventType.PROJECT_ACTIVITY) webhookConfig.webhookSecret = generateRandomWebhookSecret() webhookConfigRepository.save(webhookConfig) automationService.createForWebhookConfig(webhookConfig) @@ -90,6 +92,7 @@ class WebhookConfigService( webhookConfig.autoDisabled = false } } + dto.eventTypes?.let { webhookConfig.eventTypes = it.toMutableSet() } automationService.updateForWebhookConfig(webhookConfig) return webhookConfigRepository.save(webhookConfig) } diff --git a/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/WebhookConfigControllerTest.kt b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/WebhookConfigControllerTest.kt index 9d575968238..3c50e902aaa 100644 --- a/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/WebhookConfigControllerTest.kt +++ b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/WebhookConfigControllerTest.kt @@ -204,4 +204,29 @@ class WebhookConfigControllerTest : ProjectAuthControllerTest("/v2/projects/") { node("url").isEqualTo("https://new-url.com") } } + + @Test + @ProjectJWTAuthTestMethod + fun `creates webhook with explicit event types`() { + performProjectAuthPost( + "webhook-configs", + mapOf( + "url" to "https://hello.com", + "eventTypes" to listOf("CONTENT_DELIVERY_PUBLISH"), + ), + ).andIsOk.andAssertThatJson { + node("eventTypes").isArray.containsExactly("CONTENT_DELIVERY_PUBLISH") + } + } + + @Test + @ProjectJWTAuthTestMethod + fun `defaults event types to project activity when omitted`() { + performProjectAuthPost( + "webhook-configs", + mapOf("url" to "https://hello.com"), + ).andIsOk.andAssertThatJson { + node("eventTypes").isArray.containsExactly("PROJECT_ACTIVITY") + } + } } From caea219bc6e3def89b277bd9a03e11b9d67136a8 Mon Sep 17 00:00:00 2001 From: Daniel Krizan Date: Tue, 16 Jun 2026 15:42:06 +0200 Subject: [PATCH 04/16] chore: regenerate API schema for webhook eventTypes --- webapp/src/service/apiSchema.generated.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/webapp/src/service/apiSchema.generated.ts b/webapp/src/service/apiSchema.generated.ts index b0d993f0163..dcd8678a838 100644 --- a/webapp/src/service/apiSchema.generated.ts +++ b/webapp/src/service/apiSchema.generated.ts @@ -7708,6 +7708,8 @@ export interface components { autoDisabled: boolean; /** @description Whether the webhook is enabled. Disabled webhooks are not executed. */ enabled: boolean; + /** @description Event types this webhook is subscribed to. */ + eventTypes: ("TEST" | "PROJECT_ACTIVITY" | "CONTENT_DELIVERY_PUBLISH")[]; /** * Format: int64 * @description Date of the first failed webhook request. If the last webhook request is successful, this value is set to null. @@ -7725,6 +7727,7 @@ export interface components { }; WebhookConfigRequest: { enabled?: boolean; + eventTypes?: ("TEST" | "PROJECT_ACTIVITY" | "CONTENT_DELIVERY_PUBLISH")[]; url: string; }; WebhookTestResponse: { From 83e156c4b894445b1149f3b74a825f563fc76fb8 Mon Sep 17 00:00:00 2001 From: Daniel Krizan Date: Tue, 16 Jun 2026 15:47:03 +0200 Subject: [PATCH 05/16] refactor: extract shared WebhookDeliveryManager and gate activity webhooks on eventTypes --- .../processors/WebhookDeliveryManager.kt | 66 +++++++++++++++++++ .../processors/WebhookProcessor.kt | 59 ++--------------- .../io/tolgee/ee/WebhookAutomationTest.kt | 19 ++++++ 3 files changed, 92 insertions(+), 52 deletions(-) create mode 100644 backend/data/src/main/kotlin/io/tolgee/component/automations/processors/WebhookDeliveryManager.kt diff --git a/backend/data/src/main/kotlin/io/tolgee/component/automations/processors/WebhookDeliveryManager.kt b/backend/data/src/main/kotlin/io/tolgee/component/automations/processors/WebhookDeliveryManager.kt new file mode 100644 index 00000000000..200b688823c --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/component/automations/processors/WebhookDeliveryManager.kt @@ -0,0 +1,66 @@ +package io.tolgee.component.automations.processors + +import io.tolgee.batch.RequeueWithDelayException +import io.tolgee.component.CurrentDateProvider +import io.tolgee.constants.Message +import io.tolgee.model.webhook.WebhookConfig +import jakarta.persistence.EntityManager +import org.springframework.stereotype.Component + +@Component +class WebhookDeliveryManager( + private val webhookExecutor: WebhookExecutor, + private val currentDateProvider: CurrentDateProvider, + private val webhookAutoDisableChecker: WebhookAutoDisableChecker, + private val entityManager: EntityManager, +) { + /** + * Delivers a single webhook request and applies the shared failure handling: + * success/failure streak tracking, auto-disable, and requeue-on-failure. + * Throws [RequeueWithDelayException] when the batch job should retry. + */ + fun signExecuteAndHandle( + config: WebhookConfig, + data: WebhookRequest, + ) { + if (!config.enabled) return + + try { + webhookExecutor.signAndExecute(config, data) + updateEntity(webhookConfig = config, failing = false) + } catch (e: Exception) { + updateEntity(config, true) + if (webhookAutoDisableChecker.checkAfterFailure(config)) return + when (e) { + is WebhookRespondedWithNon200Status -> throw RequeueWithDelayException( + Message.WEBHOOK_RESPONDED_WITH_NON_200_STATUS, + cause = e, + delayInMs = 5000, + ) + + else -> throw RequeueWithDelayException( + Message.UNEXPECTED_ERROR_WHILE_EXECUTING_WEBHOOK, + cause = e, + delayInMs = 5000, + ) + } + } + } + + private fun updateEntity( + webhookConfig: WebhookConfig, + failing: Boolean, + ) { + webhookConfig.lastExecuted = currentDateProvider.date + if (!failing) { + webhookConfig.firstFailed = null + webhookConfig.autoDisableNotified = false + entityManager.persist(webhookConfig) + return + } + if (webhookConfig.firstFailed == null) { + webhookConfig.firstFailed = currentDateProvider.date + } + entityManager.persist(webhookConfig) + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/component/automations/processors/WebhookProcessor.kt b/backend/data/src/main/kotlin/io/tolgee/component/automations/processors/WebhookProcessor.kt index 268c77d0663..e54fdf915a4 100644 --- a/backend/data/src/main/kotlin/io/tolgee/component/automations/processors/WebhookProcessor.kt +++ b/backend/data/src/main/kotlin/io/tolgee/component/automations/processors/WebhookProcessor.kt @@ -2,35 +2,26 @@ package io.tolgee.component.automations.processors import io.tolgee.activity.ActivityService import io.tolgee.api.IProjectActivityModelAssembler -import io.tolgee.batch.RequeueWithDelayException -import io.tolgee.component.CurrentDateProvider import io.tolgee.component.automations.AutomationProcessor -import io.tolgee.constants.Message import io.tolgee.model.automations.AutomationAction -import io.tolgee.model.webhook.WebhookConfig -import io.tolgee.security.ProjectHolder -import jakarta.persistence.EntityManager import org.springframework.stereotype.Component @Component class WebhookProcessor( - val projectHolder: ProjectHolder, - val activityModelAssembler: IProjectActivityModelAssembler, - val activityService: ActivityService, - val currentDateProvider: CurrentDateProvider, - val webhookExecutor: WebhookExecutor, - val entityManager: EntityManager, - val webhookAutoDisableChecker: WebhookAutoDisableChecker, + private val activityModelAssembler: IProjectActivityModelAssembler, + private val activityService: ActivityService, + private val webhookDeliveryManager: WebhookDeliveryManager, ) : AutomationProcessor { override fun process( action: AutomationAction, activityRevisionId: Long?, ) { activityRevisionId ?: return + val config = action.webhookConfig ?: return + if (!config.eventTypes.contains(WebhookEventType.PROJECT_ACTIVITY)) return + val view = activityService.findProjectActivity(activityRevisionId) ?: return val activityModel = activityModelAssembler.toModel(view) - val config = action.webhookConfig ?: return - if (!config.enabled) return val data = WebhookRequest( @@ -40,42 +31,6 @@ class WebhookProcessor( activityData = activityModel, ) - try { - webhookExecutor.signAndExecute(config, data) - updateEntity(webhookConfig = config, failing = false) - } catch (e: Exception) { - updateEntity(config, true) - if (webhookAutoDisableChecker.checkAfterFailure(config)) return - when (e) { - is WebhookRespondedWithNon200Status -> throw RequeueWithDelayException( - Message.WEBHOOK_RESPONDED_WITH_NON_200_STATUS, - cause = e, - delayInMs = 5000, - ) - - else -> throw RequeueWithDelayException( - Message.UNEXPECTED_ERROR_WHILE_EXECUTING_WEBHOOK, - cause = e, - delayInMs = 5000, - ) - } - } - } - - fun updateEntity( - webhookConfig: WebhookConfig, - failing: Boolean, - ) { - webhookConfig.lastExecuted = currentDateProvider.date - if (!failing) { - webhookConfig.firstFailed = null - webhookConfig.autoDisableNotified = false - entityManager.persist(webhookConfig) - return - } - if (webhookConfig.firstFailed == null) { - webhookConfig.firstFailed = currentDateProvider.date - } - entityManager.persist(webhookConfig) + webhookDeliveryManager.signExecuteAndHandle(config, data) } } diff --git a/ee/backend/tests/src/test/kotlin/io/tolgee/ee/WebhookAutomationTest.kt b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/WebhookAutomationTest.kt index 342dd7ce593..54bf13d959c 100644 --- a/ee/backend/tests/src/test/kotlin/io/tolgee/ee/WebhookAutomationTest.kt +++ b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/WebhookAutomationTest.kt @@ -1,6 +1,7 @@ package io.tolgee.ee import io.tolgee.ProjectAuthControllerTest +import io.tolgee.component.automations.processors.WebhookEventType import io.tolgee.development.testDataBuilder.data.WebhooksTestData import io.tolgee.ee.service.WebhookConfigService import io.tolgee.fixtures.andIsOk @@ -227,6 +228,24 @@ class WebhookAutomationTest : ProjectAuthControllerTest("/v2/projects/") { getWebhookRestTemplateInvocationCount().assert.isEqualTo(invocationsBefore) } + @Test + @ProjectJWTAuthTestMethod + fun `does not send activity webhook when not subscribed to PROJECT_ACTIVITY`() { + val testData = WebhooksTestData() + testData.webhookConfig.self.eventTypes = mutableSetOf(WebhookEventType.CONTENT_DELIVERY_PUBLISH) + currentDateProvider.forcedDate = currentDateProvider.date + testDataService.saveTestData(testData.root) + userAccount = testData.user + this.projectSupplier = { testData.projectBuilder.self } + mockWebhookResponse(HttpStatus.OK) + + val invocationsBefore = getWebhookRestTemplateInvocationCount() + modifyTranslationData() + + Thread.sleep(2000) + getWebhookRestTemplateInvocationCount().assert.isEqualTo(invocationsBefore) + } + private fun modifyTranslationData() { performProjectAuthPost( "/translations", From 7973ce3d31101bb7f0bb87bf95289edee03c506c Mon Sep 17 00:00:00 2001 From: Daniel Krizan Date: Tue, 16 Jun 2026 16:12:22 +0200 Subject: [PATCH 06/16] fix: do not log webhook eventTypes as activity to avoid webhook cascade --- .../src/main/kotlin/io/tolgee/model/webhook/WebhookConfig.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/backend/data/src/main/kotlin/io/tolgee/model/webhook/WebhookConfig.kt b/backend/data/src/main/kotlin/io/tolgee/model/webhook/WebhookConfig.kt index 66034f6cb88..d980bd7220a 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/webhook/WebhookConfig.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/webhook/WebhookConfig.kt @@ -42,7 +42,10 @@ class WebhookConfig( @ActivityLoggedProp var enabled: Boolean = true - @ActivityLoggedProp + // Must stay ignored: this jsonb set is always dirty to Hibernate, so logging it would + // emit a WebhookConfig activity on every save (incl. failure/lastExecuted bookkeeping), + // which re-triggers the webhook automation and recursively fires webhooks. + @ActivityIgnoredProp @Type(JsonBinaryType::class) @Column(columnDefinition = "jsonb") var eventTypes: MutableSet = mutableSetOf(WebhookEventType.PROJECT_ACTIVITY) From 123b32aefdbfde6c19461296610f9b8f068f566d Mon Sep 17 00:00:00 2001 From: Daniel Krizan Date: Tue, 16 Jun 2026 16:16:56 +0200 Subject: [PATCH 07/16] feat: publish event when content delivery publish completes --- .../ContentDeliveryUploader.kt | 24 ++++++- .../events/OnContentDeliveryPublished.kt | 9 +++ .../ContentDeliveryUploaderTest.kt | 63 +++++++++++++++++++ 3 files changed, 94 insertions(+), 2 deletions(-) create mode 100644 backend/data/src/main/kotlin/io/tolgee/events/OnContentDeliveryPublished.kt create mode 100644 backend/data/src/test/kotlin/io/tolgee/component/contentDelivery/ContentDeliveryUploaderTest.kt diff --git a/backend/data/src/main/kotlin/io/tolgee/component/contentDelivery/ContentDeliveryUploader.kt b/backend/data/src/main/kotlin/io/tolgee/component/contentDelivery/ContentDeliveryUploader.kt index 0e39d770a0d..a0ed1359e2a 100644 --- a/backend/data/src/main/kotlin/io/tolgee/component/contentDelivery/ContentDeliveryUploader.kt +++ b/backend/data/src/main/kotlin/io/tolgee/component/contentDelivery/ContentDeliveryUploader.kt @@ -1,15 +1,18 @@ package io.tolgee.component.contentDelivery import io.tolgee.component.CurrentDateProvider +import io.tolgee.component.automations.processors.ContentDeliveryPublishWebhookData import io.tolgee.component.contentDelivery.cachePurging.ContentDeliveryCachePurgingProvider import io.tolgee.component.fileStorage.FileStorage import io.tolgee.constants.Message +import io.tolgee.events.OnContentDeliveryPublished import io.tolgee.exceptions.BadRequestException import io.tolgee.model.contentDelivery.ContentDeliveryConfig import io.tolgee.service.contentDelivery.ContentDeliveryConfigService import io.tolgee.service.export.ExportService import io.tolgee.util.Logging import io.tolgee.util.logger +import org.springframework.context.ApplicationEventPublisher import org.springframework.stereotype.Component import java.io.ByteArrayOutputStream import java.io.InputStream @@ -23,6 +26,7 @@ class ContentDeliveryUploader( private val contentDeliveryConfigService: ContentDeliveryConfigService, private val contentDeliveryCachePurgingProvider: ContentDeliveryCachePurgingProvider, private val currentDateProvider: CurrentDateProvider, + private val applicationEventPublisher: ApplicationEventPublisher, ) : Logging { fun upload(contentDeliveryConfigId: Long) { val config = contentDeliveryConfigService.get(contentDeliveryConfigId) @@ -39,9 +43,25 @@ class ContentDeliveryUploader( storeToStorage(withFullPaths, storage) purgeCacheIfConfigured(config, files.keys) - config.lastPublished = currentDateProvider.date - config.lastPublishedFiles = files.map { it.key }.toList() + val publishedDate = currentDateProvider.date + val publishedFiles = files.map { it.key }.toList() + config.lastPublished = publishedDate + config.lastPublishedFiles = publishedFiles contentDeliveryConfigService.save(config) + + applicationEventPublisher.publishEvent( + OnContentDeliveryPublished( + this, + ContentDeliveryPublishWebhookData( + projectId = config.project.id, + id = config.id, + name = config.name, + slug = config.slug, + lastPublished = publishedDate.time, + files = publishedFiles, + ), + ), + ) } private fun createZipArchive(files: Map): Map { diff --git a/backend/data/src/main/kotlin/io/tolgee/events/OnContentDeliveryPublished.kt b/backend/data/src/main/kotlin/io/tolgee/events/OnContentDeliveryPublished.kt new file mode 100644 index 00000000000..bf8aac339a0 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/events/OnContentDeliveryPublished.kt @@ -0,0 +1,9 @@ +package io.tolgee.events + +import io.tolgee.component.automations.processors.ContentDeliveryPublishWebhookData +import org.springframework.context.ApplicationEvent + +class OnContentDeliveryPublished( + source: Any, + val data: ContentDeliveryPublishWebhookData, +) : ApplicationEvent(source) diff --git a/backend/data/src/test/kotlin/io/tolgee/component/contentDelivery/ContentDeliveryUploaderTest.kt b/backend/data/src/test/kotlin/io/tolgee/component/contentDelivery/ContentDeliveryUploaderTest.kt new file mode 100644 index 00000000000..2453676f2d1 --- /dev/null +++ b/backend/data/src/test/kotlin/io/tolgee/component/contentDelivery/ContentDeliveryUploaderTest.kt @@ -0,0 +1,63 @@ +package io.tolgee.component.contentDelivery + +import io.tolgee.component.CurrentDateProvider +import io.tolgee.component.contentDelivery.cachePurging.ContentDeliveryCachePurgingProvider +import io.tolgee.component.fileStorage.FileStorage +import io.tolgee.events.OnContentDeliveryPublished +import io.tolgee.model.Project +import io.tolgee.model.contentDelivery.ContentDeliveryConfig +import io.tolgee.service.contentDelivery.ContentDeliveryConfigService +import io.tolgee.service.export.ExportService +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.springframework.context.ApplicationEventPublisher +import java.util.Date + +class ContentDeliveryUploaderTest { + @Test + fun `publishes event with the published snapshot`() { + val project = mock { on { id } doReturn 10L } + val config = mock { + on { id } doReturn 55L + on { this.project } doReturn project + on { name } doReturn "Production CDN" + on { slug } doReturn "abc123" + on { zip } doReturn false + on { contentStorage } doReturn null + on { pruneBeforePublish } doReturn false + } + + val configService = mock { on { get(55L) } doReturn config } + val storage = mock() + val storageProvider = mock { + on { getContentStorageWithDefaultClient() } doReturn storage + } + val exportService = mock { + on { export(any(), any()) } doReturn mapOf("en.json" to "{}".byteInputStream()) + } + val purgingProvider = mock { on { purgings } doReturn listOf() } + val dateProvider = mock { on { date } doReturn Date(1718539200000) } + val eventPublisher = mock() + + val uploader = ContentDeliveryUploader( + storageProvider, exportService, configService, + purgingProvider, dateProvider, eventPublisher, + ) + + uploader.upload(55L) + + val captor = argumentCaptor() + verify(eventPublisher).publishEvent(captor.capture()) + val data = captor.firstValue.data + assertThat(data.projectId).isEqualTo(10L) + assertThat(data.id).isEqualTo(55L) + assertThat(data.slug).isEqualTo("abc123") + assertThat(data.lastPublished).isEqualTo(1718539200000) + assertThat(data.files).containsExactly("en.json") + } +} From 08c68e0a3f78f2dc3f488d46edd51551aa24b801 Mon Sep 17 00:00:00 2001 From: Daniel Krizan Date: Tue, 16 Jun 2026 16:19:17 +0200 Subject: [PATCH 08/16] feat: add WEBHOOK_DISPATCH batch job for content delivery webhooks --- .../io/tolgee/batch/data/BatchJobType.kt | 6 ++ .../WebhookDispatchChunkProcessor.kt | 57 +++++++++++++++++++ .../batch/request/WebhookDispatchBjRequest.kt | 9 +++ .../batch/params/WebhookDispatchJobParams.kt | 8 +++ 4 files changed, 80 insertions(+) create mode 100644 backend/data/src/main/kotlin/io/tolgee/batch/processors/WebhookDispatchChunkProcessor.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/batch/request/WebhookDispatchBjRequest.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/model/batch/params/WebhookDispatchJobParams.kt diff --git a/backend/data/src/main/kotlin/io/tolgee/batch/data/BatchJobType.kt b/backend/data/src/main/kotlin/io/tolgee/batch/data/BatchJobType.kt index 9be55edd5dc..d7d0ee1db8f 100644 --- a/backend/data/src/main/kotlin/io/tolgee/batch/data/BatchJobType.kt +++ b/backend/data/src/main/kotlin/io/tolgee/batch/data/BatchJobType.kt @@ -21,6 +21,7 @@ import io.tolgee.batch.processors.TagKeysChunkProcessor import io.tolgee.batch.processors.TrialExpirationNoticeProcessor import io.tolgee.batch.processors.UnassignTranslationLabelChunkProcessor import io.tolgee.batch.processors.UntagKeysChunkProcessor +import io.tolgee.batch.processors.WebhookDispatchChunkProcessor import kotlin.reflect.KClass enum class BatchJobType( @@ -136,4 +137,9 @@ enum class BatchJobType( processor = NoOpChunkProcessor::class, defaultExclusive = false, ), + WEBHOOK_DISPATCH( + maxRetries = 3, + processor = WebhookDispatchChunkProcessor::class, + defaultExclusive = false, + ), } diff --git a/backend/data/src/main/kotlin/io/tolgee/batch/processors/WebhookDispatchChunkProcessor.kt b/backend/data/src/main/kotlin/io/tolgee/batch/processors/WebhookDispatchChunkProcessor.kt new file mode 100644 index 00000000000..821a74339c4 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/batch/processors/WebhookDispatchChunkProcessor.kt @@ -0,0 +1,57 @@ +package io.tolgee.batch.processors + +import com.fasterxml.jackson.databind.ObjectMapper +import io.tolgee.batch.AbstractChunkProcessor +import io.tolgee.batch.data.BatchJobDto +import io.tolgee.batch.request.WebhookDispatchBjRequest +import io.tolgee.component.automations.processors.WebhookDeliveryManager +import io.tolgee.component.automations.processors.WebhookEventType +import io.tolgee.component.automations.processors.WebhookRequest +import io.tolgee.model.batch.params.WebhookDispatchJobParams +import io.tolgee.repository.WebhookConfigRepository +import org.springframework.stereotype.Component +import kotlin.coroutines.CoroutineContext + +@Component +class WebhookDispatchChunkProcessor( + private val webhookConfigRepository: WebhookConfigRepository, + private val webhookDeliveryManager: WebhookDeliveryManager, + objectMapper: ObjectMapper, +) : AbstractChunkProcessor(objectMapper) { + override fun process( + job: BatchJobDto, + chunk: List, + coroutineContext: CoroutineContext, + ) { + val params = getParams(job) + chunk.forEach { webhookConfigId -> + val config = webhookConfigRepository.findById(webhookConfigId).orElse(null) ?: return@forEach + val data = + WebhookRequest( + webhookConfigId = config.id, + projectId = params.projectId, + eventType = WebhookEventType.CONTENT_DELIVERY_PUBLISH, + activityData = null, + contentDeliveryConfig = params.data, + ) + webhookDeliveryManager.signExecuteAndHandle(config, data) + } + } + + override fun getTarget(data: WebhookDispatchBjRequest): List = listOf(data.webhookConfigId) + + override fun getParamsType(): Class = WebhookDispatchJobParams::class.java + + override fun getTargetItemType(): Class = Long::class.java + + override fun getParams(data: WebhookDispatchBjRequest): WebhookDispatchJobParams = + WebhookDispatchJobParams().apply { + projectId = data.projectId + this.data = data.data + } + + override fun getChunkSize( + request: WebhookDispatchBjRequest, + projectId: Long?, + ): Int = 1 +} diff --git a/backend/data/src/main/kotlin/io/tolgee/batch/request/WebhookDispatchBjRequest.kt b/backend/data/src/main/kotlin/io/tolgee/batch/request/WebhookDispatchBjRequest.kt new file mode 100644 index 00000000000..c5cc240be3a --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/batch/request/WebhookDispatchBjRequest.kt @@ -0,0 +1,9 @@ +package io.tolgee.batch.request + +import io.tolgee.component.automations.processors.ContentDeliveryPublishWebhookData + +data class WebhookDispatchBjRequest( + var webhookConfigId: Long = 0, + var projectId: Long = 0, + var data: ContentDeliveryPublishWebhookData = ContentDeliveryPublishWebhookData(), +) diff --git a/backend/data/src/main/kotlin/io/tolgee/model/batch/params/WebhookDispatchJobParams.kt b/backend/data/src/main/kotlin/io/tolgee/model/batch/params/WebhookDispatchJobParams.kt new file mode 100644 index 00000000000..f21ca7fa8df --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/model/batch/params/WebhookDispatchJobParams.kt @@ -0,0 +1,8 @@ +package io.tolgee.model.batch.params + +import io.tolgee.component.automations.processors.ContentDeliveryPublishWebhookData + +class WebhookDispatchJobParams { + var projectId: Long = 0 + var data: ContentDeliveryPublishWebhookData = ContentDeliveryPublishWebhookData() +} From c520c70e28e29549b2b2f18301c5a8f3bc77e911 Mon Sep 17 00:00:00 2001 From: Daniel Krizan Date: Tue, 16 Jun 2026 16:24:02 +0200 Subject: [PATCH 09/16] feat: dispatch webhooks on content delivery publish completion --- .../repository/WebhookConfigRepository.kt | 8 + .../ContentDeliveryPublishWebhookListener.kt | 50 +++++ .../ee/ContentDeliveryPublishWebhookTest.kt | 172 ++++++++++++++++++ 3 files changed, 230 insertions(+) create mode 100644 ee/backend/app/src/main/kotlin/io/tolgee/ee/component/contentDelivery/ContentDeliveryPublishWebhookListener.kt create mode 100644 ee/backend/tests/src/test/kotlin/io/tolgee/ee/ContentDeliveryPublishWebhookTest.kt diff --git a/backend/data/src/main/kotlin/io/tolgee/repository/WebhookConfigRepository.kt b/backend/data/src/main/kotlin/io/tolgee/repository/WebhookConfigRepository.kt index 922bf3bef10..04d32505707 100644 --- a/backend/data/src/main/kotlin/io/tolgee/repository/WebhookConfigRepository.kt +++ b/backend/data/src/main/kotlin/io/tolgee/repository/WebhookConfigRepository.kt @@ -33,4 +33,12 @@ interface WebhookConfigRepository : JpaRepository { projectId: Long, pageable: Pageable, ): Page + + @Query( + """ + from WebhookConfig wc + where wc.project.id = :projectId + """, + ) + fun findAllByProjectId(projectId: Long): List } diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/component/contentDelivery/ContentDeliveryPublishWebhookListener.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/component/contentDelivery/ContentDeliveryPublishWebhookListener.kt new file mode 100644 index 00000000000..ea25448a9c1 --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/component/contentDelivery/ContentDeliveryPublishWebhookListener.kt @@ -0,0 +1,50 @@ +package io.tolgee.ee.component.contentDelivery + +import io.tolgee.batch.BatchJobService +import io.tolgee.batch.data.BatchJobType +import io.tolgee.batch.request.WebhookDispatchBjRequest +import io.tolgee.component.automations.processors.ContentDeliveryPublishWebhookData +import io.tolgee.component.automations.processors.WebhookEventType +import io.tolgee.events.OnContentDeliveryPublished +import io.tolgee.model.Project +import io.tolgee.repository.WebhookConfigRepository +import jakarta.persistence.EntityManager +import jakarta.transaction.Transactional +import org.springframework.context.annotation.Lazy +import org.springframework.scheduling.annotation.Async +import org.springframework.stereotype.Component +import org.springframework.transaction.event.TransactionPhase +import org.springframework.transaction.event.TransactionalEventListener + +@Component +class ContentDeliveryPublishWebhookListener( + private val webhookConfigRepository: WebhookConfigRepository, + private val batchJobService: BatchJobService, + private val entityManager: EntityManager, + @Lazy private val self: ContentDeliveryPublishWebhookListener, +) { + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) + @Async + fun onContentDeliveryPublished(event: OnContentDeliveryPublished) { + self.enqueueWebhooks(event.data) + } + + @Transactional + fun enqueueWebhooks(data: ContentDeliveryPublishWebhookData) { + val webhooks = + webhookConfigRepository.findAllByProjectId(data.projectId) + .filter { it.enabled && it.eventTypes.contains(WebhookEventType.CONTENT_DELIVERY_PUBLISH) } + if (webhooks.isEmpty()) return + + val project = entityManager.getReference(Project::class.java, data.projectId) + webhooks.forEach { webhook -> + batchJobService.startJob( + request = WebhookDispatchBjRequest(webhook.id, data.projectId, data), + project = project, + author = null, + type = BatchJobType.WEBHOOK_DISPATCH, + isHidden = true, + ) + } + } +} diff --git a/ee/backend/tests/src/test/kotlin/io/tolgee/ee/ContentDeliveryPublishWebhookTest.kt b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/ContentDeliveryPublishWebhookTest.kt new file mode 100644 index 00000000000..5caf8cc96db --- /dev/null +++ b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/ContentDeliveryPublishWebhookTest.kt @@ -0,0 +1,172 @@ +package io.tolgee.ee + +import io.tolgee.ProjectAuthControllerTest +import io.tolgee.component.automations.processors.ContentDeliveryPublishWebhookData +import io.tolgee.component.automations.processors.WebhookEventType +import io.tolgee.development.testDataBuilder.data.WebhooksTestData +import io.tolgee.ee.service.WebhookConfigService +import io.tolgee.events.OnContentDeliveryPublished +import io.tolgee.fixtures.waitForNotThrowing +import io.tolgee.testing.annotations.ProjectJWTAuthTestMethod +import io.tolgee.testing.assert +import net.javacrumbs.jsonunit.assertj.assertThatJson +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.whenever +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.context.ApplicationEventPublisher +import org.springframework.http.HttpEntity +import org.springframework.http.HttpMethod +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.test.context.bean.override.mockito.MockitoBean +import org.springframework.web.client.RestTemplate + +@SpringBootTest +class ContentDeliveryPublishWebhookTest : ProjectAuthControllerTest("/v2/projects/") { + @Autowired + lateinit var webhookConfigService: WebhookConfigService + + @Autowired + lateinit var eventPublisher: ApplicationEventPublisher + + @MockitoBean + @Autowired + lateinit var restTemplate: RestTemplate + + @MockitoBean + @Autowired + @Qualifier("webhookRestTemplate") + lateinit var webhookRestTemplate: RestTemplate + + @BeforeEach + fun before() { + Mockito.reset(restTemplate, webhookRestTemplate) + } + + @Test + @ProjectJWTAuthTestMethod + fun `dispatches CONTENT_DELIVERY_PUBLISH webhook with payload`() { + val testData = WebhooksTestData() + testData.webhookConfig.self.eventTypes = mutableSetOf(WebhookEventType.CONTENT_DELIVERY_PUBLISH) + testDataService.saveTestData(testData.root) + userAccount = testData.user + projectSupplier = { testData.projectBuilder.self } + + mockWebhookResponse(HttpStatus.OK) + + val invocationsBefore = getWebhookRestTemplateInvocationCount() + + eventPublisher.publishEvent( + OnContentDeliveryPublished( + this, + ContentDeliveryPublishWebhookData( + projectId = testData.projectBuilder.self.id, + id = 999L, + name = "Production CDN", + slug = "abc123", + lastPublished = 1718539200000, + files = listOf("en.json", "de.json"), + ), + ), + ) + + waitForNotThrowing(timeout = 10000) { + getWebhookRestTemplateInvocationCount().assert.isEqualTo(invocationsBefore + 1) + val callArguments = + Mockito + .mockingDetails(webhookRestTemplate) + .invocations + .last() + .arguments + val httpEntity = callArguments[2] as HttpEntity + assertThatJson(httpEntity.body!!) { + node("eventType").isEqualTo("CONTENT_DELIVERY_PUBLISH") + node("projectId").isEqualTo(testData.projectBuilder.self.id) + node("contentDeliveryConfig.slug").isEqualTo("abc123") + node("contentDeliveryConfig.files").isArray.containsExactlyInAnyOrder("en.json", "de.json") + node("activityData").isNull() + } + } + } + + @Test + @ProjectJWTAuthTestMethod + fun `does not dispatch to webhooks not subscribed to the event`() { + val testData = WebhooksTestData() + testData.webhookConfig.self.eventTypes = mutableSetOf(WebhookEventType.PROJECT_ACTIVITY) + testDataService.saveTestData(testData.root) + userAccount = testData.user + projectSupplier = { testData.projectBuilder.self } + + mockWebhookResponse(HttpStatus.OK) + + val invocationsBefore = getWebhookRestTemplateInvocationCount() + + eventPublisher.publishEvent( + OnContentDeliveryPublished( + this, + ContentDeliveryPublishWebhookData( + projectId = testData.projectBuilder.self.id, + id = 1L, + name = "CDN", + slug = "xyz", + lastPublished = null, + files = listOf(), + ), + ), + ) + + Thread.sleep(3000) + getWebhookRestTemplateInvocationCount().assert.isEqualTo(invocationsBefore) + } + + @Test + @ProjectJWTAuthTestMethod + fun `records failure streak when consumer returns 500`() { + val testData = WebhooksTestData() + testData.webhookConfig.self.eventTypes = mutableSetOf(WebhookEventType.CONTENT_DELIVERY_PUBLISH) + testDataService.saveTestData(testData.root) + userAccount = testData.user + projectSupplier = { testData.projectBuilder.self } + + mockWebhookResponse(HttpStatus.INTERNAL_SERVER_ERROR) + + eventPublisher.publishEvent( + OnContentDeliveryPublished( + this, + ContentDeliveryPublishWebhookData( + projectId = testData.projectBuilder.self.id, + id = 1L, + name = "CDN", + slug = "xyz", + lastPublished = null, + files = listOf(), + ), + ), + ) + + waitForNotThrowing(timeout = 10000) { + webhookConfigService.get(testData.webhookConfig.self.id).firstFailed.assert.isNotNull() + } + } + + private fun mockWebhookResponse(httpStatus: HttpStatus) { + doAnswer { + ResponseEntity.status(httpStatus).build() + }.whenever(webhookRestTemplate) + .exchange( + any(), + any(), + any>(), + any>(), + ) + } + + private fun getWebhookRestTemplateInvocationCount() = Mockito.mockingDetails(webhookRestTemplate).invocations.count() +} From eab135ebf51efe3e9b81f5f3732f11baf711a2b6 Mon Sep 17 00:00:00 2001 From: Daniel Krizan Date: Tue, 16 Jun 2026 16:29:46 +0200 Subject: [PATCH 10/16] test: tidy content delivery webhook tests (BeforeEach setup, robust negative check) --- .../ee/ContentDeliveryPublishWebhookTest.kt | 28 +++++++++---------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/ee/backend/tests/src/test/kotlin/io/tolgee/ee/ContentDeliveryPublishWebhookTest.kt b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/ContentDeliveryPublishWebhookTest.kt index 5caf8cc96db..c225ee90386 100644 --- a/ee/backend/tests/src/test/kotlin/io/tolgee/ee/ContentDeliveryPublishWebhookTest.kt +++ b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/ContentDeliveryPublishWebhookTest.kt @@ -44,21 +44,22 @@ class ContentDeliveryPublishWebhookTest : ProjectAuthControllerTest("/v2/project @Qualifier("webhookRestTemplate") lateinit var webhookRestTemplate: RestTemplate + lateinit var testData: WebhooksTestData + @BeforeEach fun before() { + testData = WebhooksTestData() + userAccount = testData.user + projectSupplier = { testData.projectBuilder.self } Mockito.reset(restTemplate, webhookRestTemplate) + mockWebhookResponse(HttpStatus.OK) } @Test @ProjectJWTAuthTestMethod fun `dispatches CONTENT_DELIVERY_PUBLISH webhook with payload`() { - val testData = WebhooksTestData() testData.webhookConfig.self.eventTypes = mutableSetOf(WebhookEventType.CONTENT_DELIVERY_PUBLISH) testDataService.saveTestData(testData.root) - userAccount = testData.user - projectSupplier = { testData.projectBuilder.self } - - mockWebhookResponse(HttpStatus.OK) val invocationsBefore = getWebhookRestTemplateInvocationCount() @@ -97,14 +98,10 @@ class ContentDeliveryPublishWebhookTest : ProjectAuthControllerTest("/v2/project @Test @ProjectJWTAuthTestMethod - fun `does not dispatch to webhooks not subscribed to the event`() { - val testData = WebhooksTestData() + fun `does not dispatch to webhooks subscribed to a different event type`() { + // Webhook is subscribed to PROJECT_ACTIVITY, not CONTENT_DELIVERY_PUBLISH testData.webhookConfig.self.eventTypes = mutableSetOf(WebhookEventType.PROJECT_ACTIVITY) testDataService.saveTestData(testData.root) - userAccount = testData.user - projectSupplier = { testData.projectBuilder.self } - - mockWebhookResponse(HttpStatus.OK) val invocationsBefore = getWebhookRestTemplateInvocationCount() @@ -122,18 +119,19 @@ class ContentDeliveryPublishWebhookTest : ProjectAuthControllerTest("/v2/project ), ) - Thread.sleep(3000) + Thread.sleep(2500) + getWebhookRestTemplateInvocationCount().assert.isEqualTo(invocationsBefore) + + // Confirm the count stays unchanged — no delayed dispatch + Thread.sleep(500) getWebhookRestTemplateInvocationCount().assert.isEqualTo(invocationsBefore) } @Test @ProjectJWTAuthTestMethod fun `records failure streak when consumer returns 500`() { - val testData = WebhooksTestData() testData.webhookConfig.self.eventTypes = mutableSetOf(WebhookEventType.CONTENT_DELIVERY_PUBLISH) testDataService.saveTestData(testData.root) - userAccount = testData.user - projectSupplier = { testData.projectBuilder.self } mockWebhookResponse(HttpStatus.INTERNAL_SERVER_ERROR) From 217156a3d11533186ac5ae7f38ef8942f6ff0226 Mon Sep 17 00:00:00 2001 From: Daniel Krizan Date: Tue, 16 Jun 2026 16:33:00 +0200 Subject: [PATCH 11/16] feat: add webhook event type selection to the webhook form --- .../src/constants/GlobalValidationSchema.tsx | 1 + .../developer/webhook/EventTypesSelector.tsx | 61 +++++++++++++++++++ .../developer/webhook/WebhookEditDialog.tsx | 8 +++ .../src/ee/developer/webhook/WebhookItem.tsx | 19 +++++- 4 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 webapp/src/ee/developer/webhook/EventTypesSelector.tsx diff --git a/webapp/src/constants/GlobalValidationSchema.tsx b/webapp/src/constants/GlobalValidationSchema.tsx index cbe6fda12a5..967e17c4bfd 100644 --- a/webapp/src/constants/GlobalValidationSchema.tsx +++ b/webapp/src/constants/GlobalValidationSchema.tsx @@ -462,6 +462,7 @@ export class Validation { static readonly WEBHOOK_FORM = Yup.object().shape({ url: Yup.string().required().max(255), + eventTypes: Yup.array().of(Yup.string().required()).min(1).required(), }); static readonly NEW_KEY_FORM = (t: TranslateFunction) => diff --git a/webapp/src/ee/developer/webhook/EventTypesSelector.tsx b/webapp/src/ee/developer/webhook/EventTypesSelector.tsx new file mode 100644 index 00000000000..d58acaa58d5 --- /dev/null +++ b/webapp/src/ee/developer/webhook/EventTypesSelector.tsx @@ -0,0 +1,61 @@ +import { Field } from 'formik'; +import { + Checkbox, + FormControl, + FormHelperText, + InputLabel, + ListItemText, + MenuItem, + Select, +} from '@mui/material'; +import { useTranslate } from '@tolgee/react'; + +const EVENT_TYPES = ['PROJECT_ACTIVITY', 'CONTENT_DELIVERY_PUBLISH'] as const; + +export const EventTypesSelector = () => { + const { t } = useTranslate(); + return ( + + {({ field, meta }) => ( + + + {t('webhook_form_event_types_label', 'Event types')} + + + {meta.error} + + )} + + ); +}; diff --git a/webapp/src/ee/developer/webhook/WebhookEditDialog.tsx b/webapp/src/ee/developer/webhook/WebhookEditDialog.tsx index 430f5eca533..b4a05e908f9 100644 --- a/webapp/src/ee/developer/webhook/WebhookEditDialog.tsx +++ b/webapp/src/ee/developer/webhook/WebhookEditDialog.tsx @@ -18,8 +18,12 @@ import LoadingButton from 'tg.component/common/form/LoadingButton'; import { TextField } from 'tg.component/common/form/fields/TextField'; import { useMessage } from 'tg.hooks/useSuccessMessage'; import { Validation } from 'tg.constants/GlobalValidationSchema'; +import { EventTypesSelector } from './EventTypesSelector'; type WebhookConfigModel = components['schemas']['WebhookConfigModel']; +type WebhookEventType = NonNullable< + components['schemas']['WebhookConfigRequest']['eventTypes'] +>[number]; const StyledDialogContent = styled(DialogContent)` display: grid; @@ -84,6 +88,7 @@ export const WebhookEditDialog = ({ onClose, data }: Props) => { { data-cy="webhook-form-url" /> + + +
diff --git a/webapp/src/ee/developer/webhook/WebhookItem.tsx b/webapp/src/ee/developer/webhook/WebhookItem.tsx index 45f15640ed9..0945c3befcc 100644 --- a/webapp/src/ee/developer/webhook/WebhookItem.tsx +++ b/webapp/src/ee/developer/webhook/WebhookItem.tsx @@ -88,7 +88,24 @@ export const WebhookItem = ({ data }: Props) => { > - {data.url} + + {data.url} + {data.eventTypes && data.eventTypes.length > 0 && ( + + {data.eventTypes + .map((type) => + t( + `webhook_event_type_${type.toLowerCase()}`, + type.toLowerCase().replace(/_/g, ' ') + ) + ) + .join(', ')} + + )} + {Boolean(data.lastExecuted) && ( From e80ae5808160869c17fc8985a64531ad0d0431a6 Mon Sep 17 00:00:00 2001 From: Daniel Krizan Date: Tue, 16 Jun 2026 16:43:23 +0200 Subject: [PATCH 12/16] fix: use static i18n keys for webhook event types --- .../developer/webhook/EventTypesSelector.tsx | 35 +++++++++++-------- .../src/ee/developer/webhook/WebhookItem.tsx | 23 ++++++++---- webapp/src/i18n/en.json | 3 ++ 3 files changed, 41 insertions(+), 20 deletions(-) diff --git a/webapp/src/ee/developer/webhook/EventTypesSelector.tsx b/webapp/src/ee/developer/webhook/EventTypesSelector.tsx index d58acaa58d5..fedcc0202ac 100644 --- a/webapp/src/ee/developer/webhook/EventTypesSelector.tsx +++ b/webapp/src/ee/developer/webhook/EventTypesSelector.tsx @@ -12,6 +12,25 @@ import { useTranslate } from '@tolgee/react'; const EVENT_TYPES = ['PROJECT_ACTIVITY', 'CONTENT_DELIVERY_PUBLISH'] as const; +type EventType = (typeof EVENT_TYPES)[number]; + +function eventTypeLabel( + type: EventType | string, + t: ReturnType['t'] +): string { + switch (type) { + case 'PROJECT_ACTIVITY': + return t('webhook_event_type_project_activity', 'Project activity'); + case 'CONTENT_DELIVERY_PUBLISH': + return t( + 'webhook_event_type_content_delivery_publish', + 'Content delivery publish' + ); + default: + return type; + } +} + export const EventTypesSelector = () => { const { t } = useTranslate(); return ( @@ -27,14 +46,7 @@ export const EventTypesSelector = () => { variant="standard" data-cy="webhook-event-types-selector" renderValue={(values: string[]) => - values - .map((v) => - t( - `webhook_event_type_${v.toLowerCase()}`, - v.toLowerCase().replace(/_/g, ' ') - ) - ) - .join(', ') + values.map((v) => eventTypeLabel(v, t)).join(', ') } > {EVENT_TYPES.map((type) => ( @@ -44,12 +56,7 @@ export const EventTypesSelector = () => { data-cy={`webhook-event-type-${type.toLowerCase()}`} > - + ))} diff --git a/webapp/src/ee/developer/webhook/WebhookItem.tsx b/webapp/src/ee/developer/webhook/WebhookItem.tsx index 0945c3befcc..feb0e9e33b9 100644 --- a/webapp/src/ee/developer/webhook/WebhookItem.tsx +++ b/webapp/src/ee/developer/webhook/WebhookItem.tsx @@ -26,6 +26,22 @@ import { useDateFormatter } from 'tg.hooks/useLocale'; type WebhookConfigModel = components['schemas']['WebhookConfigModel']; +type TFn = ReturnType['t']; + +function eventTypeLabel(type: string, t: TFn): string { + switch (type) { + case 'PROJECT_ACTIVITY': + return t('webhook_event_type_project_activity', 'Project activity'); + case 'CONTENT_DELIVERY_PUBLISH': + return t( + 'webhook_event_type_content_delivery_publish', + 'Content delivery publish' + ); + default: + return type; + } +} + const StyledContainer = styled('div')` display: flex; padding: 8px 16px; @@ -96,12 +112,7 @@ export const WebhookItem = ({ data }: Props) => { data-cy="webhook-item-event-types" > {data.eventTypes - .map((type) => - t( - `webhook_event_type_${type.toLowerCase()}`, - type.toLowerCase().replace(/_/g, ' ') - ) - ) + .map((type) => eventTypeLabel(type, t)) .join(', ')} )} diff --git a/webapp/src/i18n/en.json b/webapp/src/i18n/en.json index b4999fedbc4..86ee5e5bc7d 100644 --- a/webapp/src/i18n/en.json +++ b/webapp/src/i18n/en.json @@ -2922,8 +2922,11 @@ "webhook_disable_confirmation_message": "This webhook will no longer receive events until re-enabled.", "webhook_disable_confirmation_title": "Disable webhook?", "webhook_edit_success": "Webhook successfully updated!", + "webhook_event_type_content_delivery_publish": "Content delivery publish", + "webhook_event_type_project_activity": "Project activity", "webhook_form_cancel": "Cancel", "webhook_form_delete": "Delete", + "webhook_form_event_types_label": "Event types", "webhook_form_save": "Save", "webhook_form_url_label": "Target URL", "webhook_item_delete_dialog_title": "Delete webhook", From 6ba323347996dd627b135e02372b87b305c43030 Mon Sep 17 00:00:00 2001 From: Daniel Krizan Date: Tue, 16 Jun 2026 20:09:34 +0200 Subject: [PATCH 13/16] style: apply prettier formatting to webhook edit dialog --- webapp/src/ee/developer/webhook/WebhookEditDialog.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/webapp/src/ee/developer/webhook/WebhookEditDialog.tsx b/webapp/src/ee/developer/webhook/WebhookEditDialog.tsx index b4a05e908f9..a2677df8d63 100644 --- a/webapp/src/ee/developer/webhook/WebhookEditDialog.tsx +++ b/webapp/src/ee/developer/webhook/WebhookEditDialog.tsx @@ -88,7 +88,9 @@ export const WebhookEditDialog = ({ onClose, data }: Props) => { Date: Tue, 16 Jun 2026 22:38:49 +0200 Subject: [PATCH 14/16] fix: make webhook eventTypes column non-null to match JPA entity --- .../src/main/kotlin/io/tolgee/model/webhook/WebhookConfig.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/data/src/main/kotlin/io/tolgee/model/webhook/WebhookConfig.kt b/backend/data/src/main/kotlin/io/tolgee/model/webhook/WebhookConfig.kt index d980bd7220a..fc36653589d 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/webhook/WebhookConfig.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/webhook/WebhookConfig.kt @@ -17,8 +17,8 @@ import jakarta.persistence.ManyToOne import jakarta.persistence.OneToMany import jakarta.persistence.Table import jakarta.validation.constraints.NotBlank -import java.util.Date import org.hibernate.annotations.Type +import java.util.Date @Entity @ActivityLoggedEntity @@ -47,7 +47,7 @@ class WebhookConfig( // which re-triggers the webhook automation and recursively fires webhooks. @ActivityIgnoredProp @Type(JsonBinaryType::class) - @Column(columnDefinition = "jsonb") + @Column(columnDefinition = "jsonb", nullable = false) var eventTypes: MutableSet = mutableSetOf(WebhookEventType.PROJECT_ACTIVITY) @OneToMany(mappedBy = "webhookConfig", orphanRemoval = true) From 9b634006a2f7f8155f5c423a419aa7c19ae3dea5 Mon Sep 17 00:00:00 2001 From: Daniel Krizan Date: Tue, 16 Jun 2026 22:38:49 +0200 Subject: [PATCH 15/16] style: apply ktlint formatting --- .../ContentDeliveryUploaderTest.kt | 46 +++++++++++-------- .../ContentDeliveryPublishWebhookListener.kt | 3 +- .../ee/ContentDeliveryPublishWebhookTest.kt | 5 +- 3 files changed, 33 insertions(+), 21 deletions(-) diff --git a/backend/data/src/test/kotlin/io/tolgee/component/contentDelivery/ContentDeliveryUploaderTest.kt b/backend/data/src/test/kotlin/io/tolgee/component/contentDelivery/ContentDeliveryUploaderTest.kt index 2453676f2d1..4ea1563c107 100644 --- a/backend/data/src/test/kotlin/io/tolgee/component/contentDelivery/ContentDeliveryUploaderTest.kt +++ b/backend/data/src/test/kotlin/io/tolgee/component/contentDelivery/ContentDeliveryUploaderTest.kt @@ -22,32 +22,40 @@ class ContentDeliveryUploaderTest { @Test fun `publishes event with the published snapshot`() { val project = mock { on { id } doReturn 10L } - val config = mock { - on { id } doReturn 55L - on { this.project } doReturn project - on { name } doReturn "Production CDN" - on { slug } doReturn "abc123" - on { zip } doReturn false - on { contentStorage } doReturn null - on { pruneBeforePublish } doReturn false - } + val config = + mock { + on { id } doReturn 55L + on { this.project } doReturn project + on { name } doReturn "Production CDN" + on { slug } doReturn "abc123" + on { zip } doReturn false + on { contentStorage } doReturn null + on { pruneBeforePublish } doReturn false + } val configService = mock { on { get(55L) } doReturn config } val storage = mock() - val storageProvider = mock { - on { getContentStorageWithDefaultClient() } doReturn storage - } - val exportService = mock { - on { export(any(), any()) } doReturn mapOf("en.json" to "{}".byteInputStream()) - } + val storageProvider = + mock { + on { getContentStorageWithDefaultClient() } doReturn storage + } + val exportService = + mock { + on { export(any(), any()) } doReturn mapOf("en.json" to "{}".byteInputStream()) + } val purgingProvider = mock { on { purgings } doReturn listOf() } val dateProvider = mock { on { date } doReturn Date(1718539200000) } val eventPublisher = mock() - val uploader = ContentDeliveryUploader( - storageProvider, exportService, configService, - purgingProvider, dateProvider, eventPublisher, - ) + val uploader = + ContentDeliveryUploader( + storageProvider, + exportService, + configService, + purgingProvider, + dateProvider, + eventPublisher, + ) uploader.upload(55L) diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/component/contentDelivery/ContentDeliveryPublishWebhookListener.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/component/contentDelivery/ContentDeliveryPublishWebhookListener.kt index ea25448a9c1..bc2f53e9d4f 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/component/contentDelivery/ContentDeliveryPublishWebhookListener.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/component/contentDelivery/ContentDeliveryPublishWebhookListener.kt @@ -32,7 +32,8 @@ class ContentDeliveryPublishWebhookListener( @Transactional fun enqueueWebhooks(data: ContentDeliveryPublishWebhookData) { val webhooks = - webhookConfigRepository.findAllByProjectId(data.projectId) + webhookConfigRepository + .findAllByProjectId(data.projectId) .filter { it.enabled && it.eventTypes.contains(WebhookEventType.CONTENT_DELIVERY_PUBLISH) } if (webhooks.isEmpty()) return diff --git a/ee/backend/tests/src/test/kotlin/io/tolgee/ee/ContentDeliveryPublishWebhookTest.kt b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/ContentDeliveryPublishWebhookTest.kt index c225ee90386..86dde0bb743 100644 --- a/ee/backend/tests/src/test/kotlin/io/tolgee/ee/ContentDeliveryPublishWebhookTest.kt +++ b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/ContentDeliveryPublishWebhookTest.kt @@ -150,7 +150,10 @@ class ContentDeliveryPublishWebhookTest : ProjectAuthControllerTest("/v2/project ) waitForNotThrowing(timeout = 10000) { - webhookConfigService.get(testData.webhookConfig.self.id).firstFailed.assert.isNotNull() + webhookConfigService + .get(testData.webhookConfig.self.id) + .firstFailed.assert + .isNotNull() } } From 122da140f255410c43ae848e034ab0ca4548a72b Mon Sep 17 00:00:00 2001 From: Daniel Krizan Date: Tue, 16 Jun 2026 22:38:49 +0200 Subject: [PATCH 16/16] chore: regenerate api schema and data-cy types; drop dynamic event-type data-cy --- e2e/cypress/support/dataCyType.d.ts | 2 ++ webapp/src/ee/developer/webhook/EventTypesSelector.tsx | 6 +----- webapp/src/service/apiSchema.generated.ts | 6 ++++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/e2e/cypress/support/dataCyType.d.ts b/e2e/cypress/support/dataCyType.d.ts index 0ab41bf388d..f3f4e4a3e4e 100644 --- a/e2e/cypress/support/dataCyType.d.ts +++ b/e2e/cypress/support/dataCyType.d.ts @@ -960,10 +960,12 @@ declare namespace DataCy { "user-switch-item": true; "user-switch-search": true; "webhook-auto-disabled-label": true; + "webhook-event-types-selector": true; "webhook-form-cancel": true; "webhook-form-delete": true; "webhook-form-save": true; "webhook-form-url": true; + "webhook-item-event-types": true; "webhook-item-toggle": true; "webhooks-add-item-button": true; "webhooks-item-edit": true; diff --git a/webapp/src/ee/developer/webhook/EventTypesSelector.tsx b/webapp/src/ee/developer/webhook/EventTypesSelector.tsx index fedcc0202ac..bf2c8eb28a3 100644 --- a/webapp/src/ee/developer/webhook/EventTypesSelector.tsx +++ b/webapp/src/ee/developer/webhook/EventTypesSelector.tsx @@ -50,11 +50,7 @@ export const EventTypesSelector = () => { } > {EVENT_TYPES.map((type) => ( - + diff --git a/webapp/src/service/apiSchema.generated.ts b/webapp/src/service/apiSchema.generated.ts index dcd8678a838..ffc6e05b0c2 100644 --- a/webapp/src/service/apiSchema.generated.ts +++ b/webapp/src/service/apiSchema.generated.ts @@ -1628,7 +1628,8 @@ export interface components { | "ASSIGN_TRANSLATION_LABEL" | "UNASSIGN_TRANSLATION_LABEL" | "QA_CHECK" - | "NO_OP"; + | "NO_OP" + | "WEBHOOK_DISPATCH"; /** * Format: int64 * @description The time when the job was last updated (status change) @@ -3724,7 +3725,8 @@ export interface components { | "ASSIGN_TRANSLATION_LABEL" | "UNASSIGN_TRANSLATION_LABEL" | "QA_CHECK" - | "NO_OP"; + | "NO_OP" + | "WEBHOOK_DISPATCH"; }; JsonNode: unknown; JwtAuthenticationResponse: {