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/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/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/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/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/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/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/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, ) 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/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/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/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() +} 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..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 @@ -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 @@ -14,6 +17,7 @@ import jakarta.persistence.ManyToOne import jakarta.persistence.OneToMany import jakarta.persistence.Table import jakarta.validation.constraints.NotBlank +import org.hibernate.annotations.Type import java.util.Date @Entity @@ -38,6 +42,14 @@ class WebhookConfig( @ActivityLoggedProp var enabled: Boolean = true + // 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", nullable = false) + var eventTypes: MutableSet = mutableSetOf(WebhookEventType.PROJECT_ACTIVITY) + @OneToMany(mappedBy = "webhookConfig", orphanRemoval = true) var automationActions: MutableList = mutableListOf() 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/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. + + 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..4ea1563c107 --- /dev/null +++ b/backend/data/src/test/kotlin/io/tolgee/component/contentDelivery/ContentDeliveryUploaderTest.kt @@ -0,0 +1,71 @@ +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") + } +} 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/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/component/contentDelivery/ContentDeliveryPublishWebhookListener.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/component/contentDelivery/ContentDeliveryPublishWebhookListener.kt new file mode 100644 index 00000000000..bc2f53e9d4f --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/component/contentDelivery/ContentDeliveryPublishWebhookListener.kt @@ -0,0 +1,51 @@ +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/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/ContentDeliveryPublishWebhookTest.kt b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/ContentDeliveryPublishWebhookTest.kt new file mode 100644 index 00000000000..86dde0bb743 --- /dev/null +++ b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/ContentDeliveryPublishWebhookTest.kt @@ -0,0 +1,173 @@ +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 + + 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`() { + testData.webhookConfig.self.eventTypes = mutableSetOf(WebhookEventType.CONTENT_DELIVERY_PUBLISH) + testDataService.saveTestData(testData.root) + + 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 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) + + 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(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`() { + testData.webhookConfig.self.eventTypes = mutableSetOf(WebhookEventType.CONTENT_DELIVERY_PUBLISH) + testDataService.saveTestData(testData.root) + + 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() +} 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", 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") + } + } } 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..bf2c8eb28a3 --- /dev/null +++ b/webapp/src/ee/developer/webhook/EventTypesSelector.tsx @@ -0,0 +1,64 @@ +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; + +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 ( + + {({ 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..a2677df8d63 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,9 @@ 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..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; @@ -88,7 +104,19 @@ export const WebhookItem = ({ data }: Props) => { > - {data.url} + + {data.url} + {data.eventTypes && data.eventTypes.length > 0 && ( + + {data.eventTypes + .map((type) => eventTypeLabel(type, t)) + .join(', ')} + + )} + {Boolean(data.lastExecuted) && ( 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", diff --git a/webapp/src/service/apiSchema.generated.ts b/webapp/src/service/apiSchema.generated.ts index b0d993f0163..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: { @@ -7708,6 +7710,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 +7729,7 @@ export interface components { }; WebhookConfigRequest: { enabled?: boolean; + eventTypes?: ("TEST" | "PROJECT_ACTIVITY" | "CONTENT_DELIVERY_PUBLISH")[]; url: string; }; WebhookTestResponse: {