diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/notification/NotificationSettingModel.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/notification/NotificationSettingModel.kt index 778035e643..57cce8d520 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/notification/NotificationSettingModel.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/notification/NotificationSettingModel.kt @@ -6,4 +6,8 @@ import java.io.Serializable data class NotificationSettingModel( var accountSecurity: NotificationSettingGroupModel, var tasks: NotificationSettingGroupModel, -) : RepresentationModel(), Serializable + var keysAdded: NotificationSettingGroupModel, + var stringsTranslated: NotificationSettingGroupModel, + var stringsReviewed: NotificationSettingGroupModel, +) : RepresentationModel(), + Serializable diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/notification/NotificationSettingsModelAssembler.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/notification/NotificationSettingsModelAssembler.kt index be4ea0064e..c219bc6c48 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/notification/NotificationSettingsModelAssembler.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/notification/NotificationSettingsModelAssembler.kt @@ -17,6 +17,9 @@ class NotificationSettingsModelAssembler : NotificationSettingModel( accountSecurity = view.groupModel(NotificationTypeGroup.ACCOUNT_SECURITY), tasks = view.groupModel(NotificationTypeGroup.TASKS), + keysAdded = view.groupModel(NotificationTypeGroup.KEYS), + stringsTranslated = view.groupModel(NotificationTypeGroup.TRANSLATIONS), + stringsReviewed = view.groupModel(NotificationTypeGroup.REVIEWS), ) private fun List.groupModel(group: NotificationTypeGroup) = diff --git a/backend/data/src/main/kotlin/io/tolgee/activity/ActivityNotificationListener.kt b/backend/data/src/main/kotlin/io/tolgee/activity/ActivityNotificationListener.kt new file mode 100644 index 0000000000..59c0d5642a --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/activity/ActivityNotificationListener.kt @@ -0,0 +1,46 @@ +package io.tolgee.activity + +import io.tolgee.activity.data.ActivityType +import io.tolgee.events.OnProjectActivityStoredEvent +import io.tolgee.model.activity.ActivityRevision +import io.tolgee.model.key.Key +import io.tolgee.model.translation.Translation +import io.tolgee.service.activityNotification.ActivityNotificationService +import org.springframework.context.event.EventListener +import org.springframework.scheduling.annotation.Async +import org.springframework.stereotype.Component + +@Component +class ActivityNotificationListener( + private val activityNotificationService: ActivityNotificationService, +) { + private val handledActivityEntities = + mapOf( + ActivityType.CREATE_KEY to requireNotNull(Key::class.simpleName), + ActivityType.SET_TRANSLATIONS to requireNotNull(Translation::class.simpleName), + ) + + @EventListener + @Async + fun onProjectActivityStored(event: OnProjectActivityStoredEvent) { + if (event.activityRevision.modifiedEntities.isEmpty()) return + if (event.activityRevision.batchJobChunkExecution != null) return + + if (event.activityRevision.type !in handledActivityEntities.keys) return + + handleActivityRevision( + event.activityRevision, + ) + } + + private fun handleActivityRevision(activityRevision: ActivityRevision) { + val activityType = activityRevision.type!! + val expectedEntityClass = handledActivityEntities[activityType] ?: return + activityRevision.modifiedEntities + .asSequence() + .filter { it.entityClass == expectedEntityClass } + .forEach { modifiedEntity -> + activityNotificationService.createBatchJob(activityRevision, modifiedEntity) + } + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/activity/data/ActivityType.kt b/backend/data/src/main/kotlin/io/tolgee/activity/data/ActivityType.kt index fbdcf0c2cf..5a0be4fdf2 100644 --- a/backend/data/src/main/kotlin/io/tolgee/activity/data/ActivityType.kt +++ b/backend/data/src/main/kotlin/io/tolgee/activity/data/ActivityType.kt @@ -74,4 +74,7 @@ enum class ActivityType( TASK_REOPEN, TASK_KEY_UPDATE(hideInList = true), ORDER_TRANSLATION, + ACTIVITY_NOTIFICATION_KEY_CREATED(onlyCountsInList = true, hideInList = true), + ACTIVITY_NOTIFICATION_STRING_TRANSLATED(onlyCountsInList = true, hideInList = true), + ACTIVITY_NOTIFICATION_STRING_REVIEWED(onlyCountsInList = true, hideInList = true), } 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 f813c832aa..972c7e6984 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 @@ -79,4 +79,19 @@ enum class BatchJobType( maxRetries = 3, processor = TrialExpirationNoticeProcessor::class, ), + NOTIFY_KEY_CREATED( + activityType = ActivityType.ACTIVITY_NOTIFICATION_KEY_CREATED, + maxRetries = 3, + processor = ActivityNotificationChunkProcessor::class, + ), + NOTIFY_STRING_TRANSLATED( + activityType = ActivityType.SET_TRANSLATIONS, + maxRetries = 3, + processor = ActivityNotificationChunkProcessor::class, + ), + NOTIFY_STRING_REVIEWED( + activityType = ActivityType.SET_TRANSLATION_STATE, + maxRetries = 3, + processor = ActivityNotificationChunkProcessor::class, + ), } diff --git a/backend/data/src/main/kotlin/io/tolgee/batch/processors/ActivityNotificationChunkProcessor.kt b/backend/data/src/main/kotlin/io/tolgee/batch/processors/ActivityNotificationChunkProcessor.kt new file mode 100644 index 0000000000..8be7035414 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/batch/processors/ActivityNotificationChunkProcessor.kt @@ -0,0 +1,74 @@ +package io.tolgee.batch.processors + +import io.tolgee.batch.ChunkProcessor +import io.tolgee.batch.JobCharacter +import io.tolgee.batch.data.BatchJobDto +import io.tolgee.batch.request.ActivityNotificationBatchRequest +import io.tolgee.batch.request.ActivityNotificationRequest +import io.tolgee.model.batch.params.ActivityNotificationJobParams +import io.tolgee.service.activityNotification.ActivityNotificationService +import jakarta.persistence.EntityManager +import kotlinx.coroutines.ensureActive +import org.springframework.stereotype.Component +import java.time.Instant +import java.time.temporal.ChronoUnit +import java.util.Date +import kotlin.collections.component1 +import kotlin.collections.component2 +import kotlin.coroutines.CoroutineContext + +@Component +class ActivityNotificationChunkProcessor( + private val activityNotificationService: ActivityNotificationService, + private val entityManager: EntityManager, +) : ChunkProcessor { + override fun process( + job: BatchJobDto, + chunk: List, + coroutineContext: CoroutineContext, + onProgress: (Int) -> Unit, + ) { + val subChunked = chunk.chunked(1000) + var progress = 0 + val params = getParams(job) + val projectId = params.projectId ?: return + val originatingUserId = params.originatingUserId ?: return + + subChunked.forEach { subChunk -> + coroutineContext.ensureActive() + val grouped = subChunk.groupBy { it.activityType } + grouped.forEach { (activityType, items) -> + val entityIds = items.map { it.entityId } + activityNotificationService.sendActivityNotifications( + projectId = projectId, + originatingUserId = originatingUserId, + activityType = activityType, + entityIds = entityIds, + ) + } + entityManager.flush() + progress += subChunk.size + onProgress.invoke(progress) + } + } + + override fun getTarget(data: ActivityNotificationBatchRequest): List = data.items + + override fun getParamsType(): Class = ActivityNotificationJobParams::class.java + + override fun getTargetItemType(): Class = ActivityNotificationRequest::class.java + + override fun getExecuteAfter(data: ActivityNotificationBatchRequest): Date? = + Date.from(Instant.now().plus(5, ChronoUnit.SECONDS)) // TODO: Change to 5 minutes + + override fun getParams(data: ActivityNotificationBatchRequest): ActivityNotificationJobParams = + ActivityNotificationJobParams().apply { + projectId = data.items.firstOrNull()?.projectId + originatingUserId = data.items.firstOrNull()?.originatingUserId + activityType = data.items.firstOrNull()?.activityType + } + + override fun getJobCharacter(): JobCharacter = JobCharacter.FAST + + override fun getMaxPerJobConcurrency(): Int = 1 +} diff --git a/backend/data/src/main/kotlin/io/tolgee/batch/request/ActivityNotificationBatchRequest.kt b/backend/data/src/main/kotlin/io/tolgee/batch/request/ActivityNotificationBatchRequest.kt new file mode 100644 index 0000000000..d21d72345e --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/batch/request/ActivityNotificationBatchRequest.kt @@ -0,0 +1,5 @@ +package io.tolgee.batch.request + +class ActivityNotificationBatchRequest( + val items: List = emptyList(), +) diff --git a/backend/data/src/main/kotlin/io/tolgee/batch/request/ActivityNotificationRequest.kt b/backend/data/src/main/kotlin/io/tolgee/batch/request/ActivityNotificationRequest.kt new file mode 100644 index 0000000000..15c4b6a100 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/batch/request/ActivityNotificationRequest.kt @@ -0,0 +1,10 @@ +package io.tolgee.batch.request + +import io.tolgee.activity.data.ActivityType + +data class ActivityNotificationRequest( + val projectId: Long, + val originatingUserId: Long, + val entityId: Long, + val activityType: ActivityType, +) diff --git a/backend/data/src/main/kotlin/io/tolgee/model/batch/params/ActivityNotificationJobParams.kt b/backend/data/src/main/kotlin/io/tolgee/model/batch/params/ActivityNotificationJobParams.kt new file mode 100644 index 0000000000..e82f9cf003 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/model/batch/params/ActivityNotificationJobParams.kt @@ -0,0 +1,11 @@ +package io.tolgee.model.batch.params + +import io.tolgee.activity.data.ActivityType +import java.io.Serializable + +class ActivityNotificationJobParams : Serializable { + var projectId: Long? = null + var originatingUserId: Long? = null + var entityId: Long? = null + var activityType: ActivityType? = null +} diff --git a/backend/data/src/main/kotlin/io/tolgee/model/notifications/NotificationType.kt b/backend/data/src/main/kotlin/io/tolgee/model/notifications/NotificationType.kt index 9c2fdd6fce..43e4eaf55e 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/notifications/NotificationType.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/notifications/NotificationType.kt @@ -1,13 +1,21 @@ package io.tolgee.model.notifications import io.tolgee.model.notifications.NotificationTypeGroup.ACCOUNT_SECURITY +import io.tolgee.model.notifications.NotificationTypeGroup.KEYS +import io.tolgee.model.notifications.NotificationTypeGroup.REVIEWS import io.tolgee.model.notifications.NotificationTypeGroup.TASKS +import io.tolgee.model.notifications.NotificationTypeGroup.TRANSLATIONS -enum class NotificationType(val group: NotificationTypeGroup) { +enum class NotificationType( + val group: NotificationTypeGroup, +) { TASK_ASSIGNED(TASKS), TASK_COMPLETED(TASKS), TASK_CLOSED(TASKS), MFA_ENABLED(ACCOUNT_SECURITY), MFA_DISABLED(ACCOUNT_SECURITY), PASSWORD_CHANGED(ACCOUNT_SECURITY), + KEY_CREATED(KEYS), + STRING_TRANSLATED(TRANSLATIONS), + STRING_REVIEWED(REVIEWS), } diff --git a/backend/data/src/main/kotlin/io/tolgee/model/notifications/NotificationTypeGroup.kt b/backend/data/src/main/kotlin/io/tolgee/model/notifications/NotificationTypeGroup.kt index 289abfe4b6..6aac29428f 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/notifications/NotificationTypeGroup.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/notifications/NotificationTypeGroup.kt @@ -3,4 +3,7 @@ package io.tolgee.model.notifications enum class NotificationTypeGroup { ACCOUNT_SECURITY, TASKS, + KEYS, + TRANSLATIONS, + REVIEWS, } diff --git a/backend/data/src/main/kotlin/io/tolgee/service/activityNotification/ActivityNotificationService.kt b/backend/data/src/main/kotlin/io/tolgee/service/activityNotification/ActivityNotificationService.kt new file mode 100644 index 0000000000..8a52908035 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/service/activityNotification/ActivityNotificationService.kt @@ -0,0 +1,88 @@ +package io.tolgee.service.activityNotification + +import io.tolgee.activity.data.ActivityType +import io.tolgee.batch.BatchJobService +import io.tolgee.batch.data.BatchJobType +import io.tolgee.batch.request.ActivityNotificationBatchRequest +import io.tolgee.batch.request.ActivityNotificationRequest +import io.tolgee.model.activity.ActivityModifiedEntity +import io.tolgee.model.activity.ActivityRevision +import io.tolgee.model.notifications.Notification +import io.tolgee.model.notifications.NotificationType +import io.tolgee.service.notification.NotificationService +import io.tolgee.service.project.ProjectService +import io.tolgee.service.security.UserAccountService +import org.springframework.stereotype.Service +import java.time.Duration + +@Service +class ActivityNotificationService( + val notificationService: NotificationService, + val userAccountService: UserAccountService, + val projectService: ProjectService, + val batchJobService: BatchJobService, +) { + fun createBatchJob( + activityRevision: ActivityRevision, + modifiedEntity: ActivityModifiedEntity, + ) { + val projectId = activityRevision.projectId ?: return + val activityType = activityRevision.type ?: return + val originatingUserId = activityRevision.authorId ?: return + + val batchJobType = + when (activityType) { + ActivityType.CREATE_KEY -> BatchJobType.NOTIFY_KEY_CREATED + ActivityType.SET_TRANSLATIONS -> BatchJobType.NOTIFY_STRING_TRANSLATED + ActivityType.SET_TRANSLATION_STATE -> BatchJobType.NOTIFY_STRING_REVIEWED + else -> return + } + + val request = + ActivityNotificationRequest( + projectId = projectId, + originatingUserId = originatingUserId, + entityId = modifiedEntity.entityId, + activityType = activityType, + ) + + val batchRequest = ActivityNotificationBatchRequest(items = listOf(request)) + + batchJobService.startJob( + request = batchRequest, + project = projectService.get(projectId), + author = userAccountService.get(originatingUserId), + type = batchJobType, + isHidden = false, + debounceDuration = Duration.ofSeconds(5), // TODO: Change to 5 minutes + debouncingKeyProvider = { params -> "${batchJobType.name}:${params.projectId}" }, + ) + } + + fun sendActivityNotifications( + projectId: Long, + originatingUserId: Long, + activityType: ActivityType, + entityIds: List, + ) { + val notification = Notification() + notification.type = + when (activityType) { + ActivityType.CREATE_KEY, ActivityType.ACTIVITY_NOTIFICATION_KEY_CREATED, + -> NotificationType.KEY_CREATED + ActivityType.SET_TRANSLATIONS, ActivityType.ACTIVITY_NOTIFICATION_STRING_TRANSLATED, + -> NotificationType.STRING_TRANSLATED + ActivityType.SET_TRANSLATION_STATE, ActivityType.ACTIVITY_NOTIFICATION_STRING_REVIEWED, + -> NotificationType.STRING_REVIEWED + else -> throw IllegalArgumentException("Unsupported activity type for notification") + } + + // TODO: This is just for testing purposes. Here we need to properly handle notifications. + + notification.originatingUser = userAccountService.get(originatingUserId) + notification.user = userAccountService.get(originatingUserId) + notification.project = projectService.get(projectId) + + notificationService.notify(notification) + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/service/notification/EmailNotificationComposer.kt b/backend/data/src/main/kotlin/io/tolgee/service/notification/EmailNotificationComposer.kt index 48f7c43f6e..ed64e0ed11 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/notification/EmailNotificationComposer.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/notification/EmailNotificationComposer.kt @@ -26,5 +26,7 @@ class EmailNotificationComposer( -> mfaEmailComposer NotificationType.PASSWORD_CHANGED, -> passwordChangedEmailComposer + // TODO: Add notification types for activity notification and remove line below + else -> throw IllegalArgumentException("Unsupported notification type for email composition") }.composeEmail(notification) } diff --git a/webapp/src/component/layout/Notifications/KeyCreatedItem.tsx b/webapp/src/component/layout/Notifications/KeyCreatedItem.tsx new file mode 100644 index 0000000000..99d8ebdb77 --- /dev/null +++ b/webapp/src/component/layout/Notifications/KeyCreatedItem.tsx @@ -0,0 +1,29 @@ +import { default as React, FunctionComponent } from 'react'; +import { T } from '@tolgee/react'; +import { + NotificationItem, + NotificationItemProps, +} from 'tg.component/layout/Notifications/NotificationItem'; +import { LINKS } from 'tg.constants/links'; + +type KeyCreatedItemProps = NotificationItemProps; + +export const KeyCreatedItem: FunctionComponent = ({ + notification, + ...props +}) => { + return ( + + {notification.originatingUser?.name} + {'\u205F'} + + {'\u205F'} + + ); +}; diff --git a/webapp/src/component/layout/Notifications/NotificationTypeMap.tsx b/webapp/src/component/layout/Notifications/NotificationTypeMap.tsx index cb0c5b5317..c8ca7787db 100644 --- a/webapp/src/component/layout/Notifications/NotificationTypeMap.tsx +++ b/webapp/src/component/layout/Notifications/NotificationTypeMap.tsx @@ -4,6 +4,9 @@ import { TaskClosedItem } from 'tg.component/layout/Notifications/TaskClosedItem import { MfaEnabledItem } from 'tg.component/layout/Notifications/MfaEnabledItem'; import { MfaDisabledItem } from 'tg.component/layout/Notifications/MfaDisabledItem'; import { PasswordChangedItem } from 'tg.component/layout/Notifications/PasswordChangedItem'; +import { KeyCreatedItem } from 'tg.component/layout/Notifications/KeyCreatedItem'; +import { StringTranslatedItem } from 'tg.component/layout/Notifications/StringTranslatedItem'; +import { StringReviewedItem } from 'tg.component/layout/Notifications/StringReviewedItem'; import { components } from 'tg.service/apiSchema.generated'; import { NotificationItemProps } from 'tg.component/layout/Notifications/NotificationItem'; import React from 'react'; @@ -20,4 +23,7 @@ export const notificationComponents: NotificationsComponentMap = { MFA_ENABLED: MfaEnabledItem, MFA_DISABLED: MfaDisabledItem, PASSWORD_CHANGED: PasswordChangedItem, + KEY_CREATED: KeyCreatedItem, + STRING_TRANSLATED: StringTranslatedItem, + STRING_REVIEWED: StringReviewedItem, }; diff --git a/webapp/src/component/layout/Notifications/StringReviewedItem.tsx b/webapp/src/component/layout/Notifications/StringReviewedItem.tsx new file mode 100644 index 0000000000..5de86edfd5 --- /dev/null +++ b/webapp/src/component/layout/Notifications/StringReviewedItem.tsx @@ -0,0 +1,28 @@ +import { default as React, FunctionComponent } from 'react'; +import { T } from '@tolgee/react'; +import { + NotificationItem, + NotificationItemProps, +} from 'tg.component/layout/Notifications/NotificationItem'; +import { LINKS } from 'tg.constants/links'; + +type StringReviewedItem = NotificationItemProps; + +export const StringReviewedItem: FunctionComponent = ({ + notification, + ...props +}) => { + return ( + + {notification.originatingUser?.name} + {'\u205F'} + + + ); +}; diff --git a/webapp/src/component/layout/Notifications/StringTranslatedItem.tsx b/webapp/src/component/layout/Notifications/StringTranslatedItem.tsx new file mode 100644 index 0000000000..d6efe4274d --- /dev/null +++ b/webapp/src/component/layout/Notifications/StringTranslatedItem.tsx @@ -0,0 +1,28 @@ +import { default as React, FunctionComponent } from 'react'; +import { T } from '@tolgee/react'; +import { + NotificationItem, + NotificationItemProps, +} from 'tg.component/layout/Notifications/NotificationItem'; +import { LINKS } from 'tg.constants/links'; + +type StringTranslatedItem = NotificationItemProps; + +export const StringTranslatedItem: FunctionComponent = ({ + notification, + ...props +}) => { + return ( + + {notification.originatingUser?.name} + {'\u205F'} + + + ); +}; diff --git a/webapp/src/service/apiSchema.generated.ts b/webapp/src/service/apiSchema.generated.ts index 86967f8ed1..273fadf047 100644 --- a/webapp/src/service/apiSchema.generated.ts +++ b/webapp/src/service/apiSchema.generated.ts @@ -2991,7 +2991,10 @@ export interface components { | "TASK_CLOSED" | "MFA_ENABLED" | "MFA_DISABLED" - | "PASSWORD_CHANGED"; + | "PASSWORD_CHANGED" + | "KEY_CREATED" + | "STRING_TRANSLATED" + | "STRING_REVIEWED"; }; NotificationSettingGroupModel: { email: boolean; @@ -2999,6 +3002,9 @@ export interface components { }; NotificationSettingModel: { accountSecurity: components["schemas"]["NotificationSettingGroupModel"]; + keysAdded: components["schemas"]["NotificationSettingGroupModel"]; + stringsReviewed: components["schemas"]["NotificationSettingGroupModel"]; + stringsTranslated: components["schemas"]["NotificationSettingGroupModel"]; tasks: components["schemas"]["NotificationSettingGroupModel"]; }; NotificationSettingsRequest: { @@ -3007,7 +3013,7 @@ export interface components { /** @description True if the setting should be enabled, false for disabled */ enabled: boolean; /** @example TASKS */ - group: "ACCOUNT_SECURITY" | "TASKS"; + group: "ACCOUNT_SECURITY" | "TASKS" | "KEYS" | "TRANSLATIONS" | "REVIEWS"; }; NotificationsMarkSeenRequest: { /** diff --git a/webapp/src/views/userSettings/notifications/NotificationsView.tsx b/webapp/src/views/userSettings/notifications/NotificationsView.tsx index b1f5711215..1b68d11206 100644 --- a/webapp/src/views/userSettings/notifications/NotificationsView.tsx +++ b/webapp/src/views/userSettings/notifications/NotificationsView.tsx @@ -79,6 +79,33 @@ export const NotificationsView: React.FC = () => { afterChange={() => settingsLoadable.refetch()} /> )} + settingsLoadable.refetch()} + /> + settingsLoadable.refetch()} + /> + settingsLoadable.refetch()} + /> );