Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,8 @@ import java.io.Serializable
data class NotificationSettingModel(
var accountSecurity: NotificationSettingGroupModel,
var tasks: NotificationSettingGroupModel,
) : RepresentationModel<NotificationSettingModel>(), Serializable
var keysAdded: NotificationSettingGroupModel,
var stringsTranslated: NotificationSettingGroupModel,
var stringsReviewed: NotificationSettingGroupModel,
) : RepresentationModel<NotificationSettingModel>(),
Serializable
Original file line number Diff line number Diff line change
Expand Up @@ -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<NotificationSetting>.groupModel(group: NotificationTypeGroup) =
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
15 changes: 15 additions & 0 deletions backend/data/src/main/kotlin/io/tolgee/batch/data/BatchJobType.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
}
Original file line number Diff line number Diff line change
@@ -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<ActivityNotificationBatchRequest, ActivityNotificationJobParams, ActivityNotificationRequest> {
override fun process(
job: BatchJobDto,
chunk: List<ActivityNotificationRequest>,
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<ActivityNotificationRequest> = data.items

override fun getParamsType(): Class<ActivityNotificationJobParams> = ActivityNotificationJobParams::class.java

override fun getTargetItemType(): Class<ActivityNotificationRequest> = 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
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package io.tolgee.batch.request

class ActivityNotificationBatchRequest(
val items: List<ActivityNotificationRequest> = emptyList(),
)
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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),
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,7 @@ package io.tolgee.model.notifications
enum class NotificationTypeGroup {
ACCOUNT_SECURITY,
TASKS,
KEYS,
TRANSLATIONS,
REVIEWS,
}
Original file line number Diff line number Diff line change
@@ -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<Long>,
) {
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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
29 changes: 29 additions & 0 deletions webapp/src/component/layout/Notifications/KeyCreatedItem.tsx
Original file line number Diff line number Diff line change
@@ -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<KeyCreatedItemProps> = ({
notification,
...props
}) => {
return (
<NotificationItem
notification={notification}
destinationUrl={LINKS.PROJECT_TRANSLATIONS.build({
projectId: notification.project!.id,
})}
{...props}
>
<b>{notification.originatingUser?.name}</b>
{'\u205F'}
<T keyName="notifications-key-created" />
{'\u205F'}
</NotificationItem>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
};
Loading