From da3ccc756ea0792f013be4bd6423bfde9e83405b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Standa=20Nov=C3=A1k?= Date: Tue, 14 Jan 2025 15:38:00 +0100 Subject: [PATCH 1/3] feat: Notifications MVP (#2813) Partially implements #2577. --- .../v2/controllers/NotificationController.kt | 44 +++++++ .../notification/NotificationEnhancer.kt | 15 +++ .../hateoas/notification/NotificationModel.kt | 12 ++ .../NotificationModelAssembler.kt | 32 +++++ .../ProjectNotificationEnhancer.kt | 21 +++ .../controllers/NotificationControllerTest.kt | 41 ++++++ .../testDataBuilder/TestDataService.kt | 22 ++-- .../builders/NotificationBuilder.kt | 13 ++ .../builders/UserAccountBuilder.kt | 4 + .../data/NotificationsTestData.kt | 26 ++++ .../kotlin/io/tolgee/model/Notification.kt | 24 ++++ .../repository/NotificationRepository.kt | 29 ++++ .../notification/NotificationService.kt | 23 ++++ .../tolgee/service/project/ProjectService.kt | 4 + .../main/resources/db/changelog/schema.xml | 34 +++++ .../e2e/tasks/tasksNotifications.cy.ts | 11 +- e2e/cypress/support/dataCyType.d.ts | 2 + .../ee/component/TaskNotificationEnhancer.kt | 25 ++++ .../ee/service/AssigneeNotificationService.kt | 11 ++ .../io/tolgee/ee/service/TaskService.kt | 9 ++ .../v2/controllers/task/TaskControllerTest.kt | 10 +- .../component/layout/TopBar/Notifications.tsx | 124 ++++++++++++++++++ webapp/src/component/layout/TopBar/TopBar.tsx | 2 + webapp/src/service/apiSchema.generated.ts | 67 ++++++++++ 24 files changed, 592 insertions(+), 13 deletions(-) create mode 100644 backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/NotificationController.kt create mode 100644 backend/api/src/main/kotlin/io/tolgee/hateoas/notification/NotificationEnhancer.kt create mode 100644 backend/api/src/main/kotlin/io/tolgee/hateoas/notification/NotificationModel.kt create mode 100644 backend/api/src/main/kotlin/io/tolgee/hateoas/notification/NotificationModelAssembler.kt create mode 100644 backend/api/src/main/kotlin/io/tolgee/hateoas/notification/ProjectNotificationEnhancer.kt create mode 100644 backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/NotificationControllerTest.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/NotificationBuilder.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/NotificationsTestData.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/model/Notification.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/repository/NotificationRepository.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/service/notification/NotificationService.kt create mode 100644 ee/backend/app/src/main/kotlin/io/tolgee/ee/component/TaskNotificationEnhancer.kt create mode 100644 webapp/src/component/layout/TopBar/Notifications.tsx diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/NotificationController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/NotificationController.kt new file mode 100644 index 0000000000..9dbfe46de1 --- /dev/null +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/NotificationController.kt @@ -0,0 +1,44 @@ +package io.tolgee.api.v2.controllers + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import io.tolgee.hateoas.notification.NotificationEnhancer +import io.tolgee.hateoas.notification.NotificationModel +import io.tolgee.hateoas.notification.NotificationModelAssembler +import io.tolgee.model.Notification +import io.tolgee.security.authentication.AllowApiAccess +import io.tolgee.security.authentication.AuthenticationFacade +import io.tolgee.service.notification.NotificationService +import org.springdoc.core.annotations.ParameterObject +import org.springframework.data.domain.Pageable +import org.springframework.data.web.PagedResourcesAssembler +import org.springframework.hateoas.PagedModel +import org.springframework.web.bind.annotation.CrossOrigin +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@CrossOrigin(origins = ["*"]) +@RequestMapping( + value = [ + "/v2/notifications", + ], +) +@Tag(name = "Notifications", description = "Manipulates notifications") +class NotificationController( + private val notificationService: NotificationService, + private val authenticationFacade: AuthenticationFacade, + private val enhancers: List, + private val pagedResourcesAssembler: PagedResourcesAssembler, +) { + @GetMapping + @Operation(summary = "Gets notifications of the currently logged in user, newest is first.") + @AllowApiAccess + fun getNotifications( + @ParameterObject pageable: Pageable, + ): PagedModel { + val notifications = notificationService.getNotifications(authenticationFacade.authenticatedUser.id, pageable) + return pagedResourcesAssembler.toModel(notifications, NotificationModelAssembler(enhancers, notifications)) + } +} diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/notification/NotificationEnhancer.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/notification/NotificationEnhancer.kt new file mode 100644 index 0000000000..2518693234 --- /dev/null +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/notification/NotificationEnhancer.kt @@ -0,0 +1,15 @@ +package io.tolgee.hateoas.notification + +import io.tolgee.model.Notification + +/** + * Dynamic component enhancing notifications by additional information if eligible, + * e.g. adding linked task. + */ +fun interface NotificationEnhancer { + /** + * Takes list of input Notification and output NotificationModel. + * It iterates over the pairs and alters the output NotificationModel by enhancing it of the new information. + */ + fun enhanceNotifications(notifications: Map) +} diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/notification/NotificationModel.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/notification/NotificationModel.kt new file mode 100644 index 0000000000..4fd5bd7cba --- /dev/null +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/notification/NotificationModel.kt @@ -0,0 +1,12 @@ +package io.tolgee.hateoas.notification + +import io.tolgee.hateoas.project.SimpleProjectModel +import io.tolgee.hateoas.task.TaskModel +import org.springframework.hateoas.RepresentationModel +import java.io.Serializable + +data class NotificationModel( + val id: Long, + var project: SimpleProjectModel? = null, + var linkedTask: TaskModel? = null, +) : RepresentationModel(), Serializable diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/notification/NotificationModelAssembler.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/notification/NotificationModelAssembler.kt new file mode 100644 index 0000000000..fb86ac9e2c --- /dev/null +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/notification/NotificationModelAssembler.kt @@ -0,0 +1,32 @@ +package io.tolgee.hateoas.notification + +import io.tolgee.api.v2.controllers.NotificationController +import io.tolgee.model.Notification +import org.springframework.data.domain.Page +import org.springframework.hateoas.server.mvc.RepresentationModelAssemblerSupport + +class NotificationModelAssembler( + private val enhancers: List, + private val notifications: Page, +) : RepresentationModelAssemblerSupport( + NotificationController::class.java, + NotificationModel::class.java, + ) { + private val prefetchedNotifications = + run { + val notificationsWithModel = + notifications.content.associateWith { notification -> + NotificationModel( + id = notification.id, + ) + } + enhancers.forEach { enhancer -> + enhancer.enhanceNotifications(notificationsWithModel) + } + notificationsWithModel + } + + override fun toModel(view: Notification): NotificationModel { + return prefetchedNotifications[view] ?: throw IllegalStateException("Notification $view was not found") + } +} diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/notification/ProjectNotificationEnhancer.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/notification/ProjectNotificationEnhancer.kt new file mode 100644 index 0000000000..316580b221 --- /dev/null +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/notification/ProjectNotificationEnhancer.kt @@ -0,0 +1,21 @@ +package io.tolgee.hateoas.notification + +import io.tolgee.hateoas.project.SimpleProjectModelAssembler +import io.tolgee.model.Notification +import io.tolgee.service.project.ProjectService +import org.springframework.stereotype.Component + +@Component +class ProjectNotificationEnhancer( + private val projectService: ProjectService, + private val simpleProjectModelAssembler: SimpleProjectModelAssembler, +) : NotificationEnhancer { + override fun enhanceNotifications(notifications: Map) { + val projectIds = notifications.mapNotNull { (source, _) -> source.project?.id }.distinct() + val projects = projectService.findAll(projectIds).associateBy { it.id } + + notifications.forEach { (source, target) -> + target.project = source.project?.id.let { projects[it] }?.let { simpleProjectModelAssembler.toModel(it) } + } + } +} diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/NotificationControllerTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/NotificationControllerTest.kt new file mode 100644 index 0000000000..7b6e59b5b4 --- /dev/null +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/NotificationControllerTest.kt @@ -0,0 +1,41 @@ +package io.tolgee.api.v2.controllers + +import io.tolgee.development.testDataBuilder.data.NotificationsTestData +import io.tolgee.fixtures.andAssertThatJson +import io.tolgee.testing.AuthorizedControllerTest +import org.junit.jupiter.api.Test + +class NotificationControllerTest : AuthorizedControllerTest() { + @Test + fun `gets notifications from newest`() { + val testData = NotificationsTestData() + + (101L..103).forEach { i -> + executeInNewTransaction { + val task = + testData.projectBuilder.addTask { + this.name = "Notification task $i" + this.language = testData.englishLanguage + this.author = testData.originatingUser.self + this.number = i + } + + testData.userAccountBuilder.addNotification { + this.user = testData.user + this.project = testData.project + this.linkedTask = task.self + this.originatingUser = testData.originatingUser.self + } + } + } + + testDataService.saveTestData(testData.root) + loginAsUser(testData.user.username) + + performAuthGet("/v2/notifications").andAssertThatJson { + node("_embedded.notificationModelList[0].linkedTask.name").isEqualTo("Notification task 103") + node("_embedded.notificationModelList[1].linkedTask.name").isEqualTo("Notification task 102") + node("_embedded.notificationModelList[2].linkedTask.name").isEqualTo("Notification task 101") + } + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/TestDataService.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/TestDataService.kt index 4699501ec9..306dcb15e8 100644 --- a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/TestDataService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/TestDataService.kt @@ -2,15 +2,7 @@ package io.tolgee.development.testDataBuilder import io.tolgee.activity.ActivityHolder import io.tolgee.component.eventListeners.LanguageStatsListener -import io.tolgee.development.testDataBuilder.builders.BatchJobBuilder -import io.tolgee.development.testDataBuilder.builders.ImportBuilder -import io.tolgee.development.testDataBuilder.builders.KeyBuilder -import io.tolgee.development.testDataBuilder.builders.PatBuilder -import io.tolgee.development.testDataBuilder.builders.ProjectBuilder -import io.tolgee.development.testDataBuilder.builders.TestDataBuilder -import io.tolgee.development.testDataBuilder.builders.TranslationBuilder -import io.tolgee.development.testDataBuilder.builders.UserAccountBuilder -import io.tolgee.development.testDataBuilder.builders.UserPreferencesBuilder +import io.tolgee.development.testDataBuilder.builders.* import io.tolgee.development.testDataBuilder.builders.slack.SlackUserConnectionBuilder import io.tolgee.service.TenantService import io.tolgee.service.automations.AutomationService @@ -25,6 +17,7 @@ import io.tolgee.service.key.TagService import io.tolgee.service.language.LanguageService import io.tolgee.service.machineTranslation.MtCreditBucketService import io.tolgee.service.machineTranslation.MtServiceConfigService +import io.tolgee.service.notification.NotificationService import io.tolgee.service.organization.OrganizationRoleService import io.tolgee.service.organization.OrganizationService import io.tolgee.service.project.LanguageStatsService @@ -75,6 +68,7 @@ class TestDataService( private val userPreferencesService: UserPreferencesService, private val languageStatsService: LanguageStatsService, private val patService: PatService, + private val notificationService: NotificationService, private val namespaceService: NamespaceService, private val bigMetaService: BigMetaService, private val activityHolder: ActivityHolder, @@ -114,6 +108,7 @@ class TestDataService( executeInNewTransaction(transactionManager) { saveProjectData(builder) + saveNotifications(builder) finalize() } @@ -464,6 +459,15 @@ class TestDataService( } } + private fun saveNotifications(builder: TestDataBuilder) { + builder.data.userAccounts + .flatMap { it.data.notifications } + .sortedBy { it.self.linkedTask?.name } + .forEach { + notificationService.save(it.self) + } + } + private fun saveUserPreferences(data: List) { data.forEach { userPreferencesService.save(it.self) } } diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/NotificationBuilder.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/NotificationBuilder.kt new file mode 100644 index 0000000000..e7c03bc08b --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/NotificationBuilder.kt @@ -0,0 +1,13 @@ +package io.tolgee.development.testDataBuilder.builders + +import io.tolgee.development.testDataBuilder.EntityDataBuilder +import io.tolgee.model.Notification + +class NotificationBuilder( + val userAccountBuilder: UserAccountBuilder, +) : EntityDataBuilder { + override var self: Notification = + Notification().apply { + this.user = userAccountBuilder.self + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/UserAccountBuilder.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/UserAccountBuilder.kt index efe470e20a..cd70417b00 100644 --- a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/UserAccountBuilder.kt +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/UserAccountBuilder.kt @@ -2,6 +2,7 @@ package io.tolgee.development.testDataBuilder.builders import io.tolgee.development.testDataBuilder.FT import io.tolgee.development.testDataBuilder.builders.slack.SlackUserConnectionBuilder +import io.tolgee.model.Notification import io.tolgee.model.Pat import io.tolgee.model.UserAccount import io.tolgee.model.UserPreferences @@ -20,6 +21,7 @@ class UserAccountBuilder( var userPreferences: UserPreferencesBuilder? = null var pats: MutableList = mutableListOf() var slackUserConnections: MutableList = mutableListOf() + var notifications: MutableList = mutableListOf() } var data = DATA() @@ -37,4 +39,6 @@ class UserAccountBuilder( fun addPat(ft: FT) = addOperation(data.pats, ft) fun addSlackUserConnection(ft: FT) = addOperation(data.slackUserConnections, ft) + + fun addNotification(ft: FT) = addOperation(data.notifications, ft) } diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/NotificationsTestData.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/NotificationsTestData.kt new file mode 100644 index 0000000000..73382c89f7 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/NotificationsTestData.kt @@ -0,0 +1,26 @@ +package io.tolgee.development.testDataBuilder.data + +class NotificationsTestData : BaseTestData() { + val originatingUser = + root.addUserAccount { + name = "originating user" + username = "originatingUser" + } + + val task = + projectBuilder.addTask { + this.name = "Notification task" + this.language = englishLanguage + this.author = originatingUser.self + } + + val notificationBuilder = + userAccountBuilder.addNotification { + this.user = this@NotificationsTestData.user + this.project = this@NotificationsTestData.project + this.linkedTask = task.self + this.originatingUser = this@NotificationsTestData.originatingUser.self + } + + val notification = notificationBuilder.self +} diff --git a/backend/data/src/main/kotlin/io/tolgee/model/Notification.kt b/backend/data/src/main/kotlin/io/tolgee/model/Notification.kt new file mode 100644 index 0000000000..51e51fbdbc --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/model/Notification.kt @@ -0,0 +1,24 @@ +package io.tolgee.model + +import io.tolgee.model.task.Task +import jakarta.persistence.* + +@Entity +@Table( + indexes = [ + Index(columnList = "user_id"), + ], +) +class Notification : StandardAuditModel() { + @ManyToOne(fetch = FetchType.LAZY) + lateinit var user: UserAccount + + @ManyToOne(fetch = FetchType.LAZY) + var project: Project? = null + + @ManyToOne(fetch = FetchType.LAZY) + var originatingUser: UserAccount? = null + + @ManyToOne(fetch = FetchType.LAZY) + var linkedTask: Task? = null +} diff --git a/backend/data/src/main/kotlin/io/tolgee/repository/NotificationRepository.kt b/backend/data/src/main/kotlin/io/tolgee/repository/NotificationRepository.kt new file mode 100644 index 0000000000..7100f2d1da --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/repository/NotificationRepository.kt @@ -0,0 +1,29 @@ +package io.tolgee.repository + +import io.tolgee.model.Notification +import org.springframework.context.annotation.Lazy +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.stereotype.Repository + +@Repository +@Lazy +interface NotificationRepository : JpaRepository { + @Query( + """ + SELECT n + FROM Notification n + LEFT JOIN FETCH n.user AS u + LEFT JOIN FETCH n.originatingUser + LEFT JOIN FETCH n.linkedTask + WHERE u.id = :userId + ORDER BY n.id DESC + """, + ) + fun fetchNotificationsByUserId( + userId: Long, + pageable: Pageable, + ): Page +} diff --git a/backend/data/src/main/kotlin/io/tolgee/service/notification/NotificationService.kt b/backend/data/src/main/kotlin/io/tolgee/service/notification/NotificationService.kt new file mode 100644 index 0000000000..49c9df863b --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/service/notification/NotificationService.kt @@ -0,0 +1,23 @@ +package io.tolgee.service.notification + +import io.tolgee.model.Notification +import io.tolgee.repository.NotificationRepository +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.stereotype.Service + +@Service +class NotificationService( + private val notificationRepository: NotificationRepository, +) { + fun getNotifications( + userId: Long, + pageable: Pageable, + ): Page { + return notificationRepository.fetchNotificationsByUserId(userId, pageable) + } + + fun save(notification: Notification) { + notificationRepository.save(notification) + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/service/project/ProjectService.kt b/backend/data/src/main/kotlin/io/tolgee/service/project/ProjectService.kt index bbbb6f18c3..6638c53a55 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/project/ProjectService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/project/ProjectService.kt @@ -136,6 +136,10 @@ class ProjectService( return projectRepository.find(id) } + fun findAll(ids: List): List { + return projectRepository.findAllById(ids) + } + @Transactional fun getView(id: Long): ProjectWithLanguagesView { val perms = permissionService.getProjectPermissionData(id, authenticationFacade.authenticatedUser.id) diff --git a/backend/data/src/main/resources/db/changelog/schema.xml b/backend/data/src/main/resources/db/changelog/schema.xml index 9194b20b8d..2a9117ed0c 100644 --- a/backend/data/src/main/resources/db/changelog/schema.xml +++ b/backend/data/src/main/resources/db/changelog/schema.xml @@ -4052,4 +4052,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/e2e/cypress/e2e/tasks/tasksNotifications.cy.ts b/e2e/cypress/e2e/tasks/tasksNotifications.cy.ts index 656f9fee17..af556bf4ca 100644 --- a/e2e/cypress/e2e/tasks/tasksNotifications.cy.ts +++ b/e2e/cypress/e2e/tasks/tasksNotifications.cy.ts @@ -23,7 +23,7 @@ describe('tasks notifications', () => { waitForGlobalLoading(); }); - it('sends email to assignee of newly created task', () => { + it('sends email to assignee of newly created task and creates notification', () => { cy.gcy('tasks-header-add-task').click(); cy.gcy('create-task-field-name').type('New review task'); cy.gcy('create-task-field-languages').click(); @@ -33,6 +33,9 @@ describe('tasks notifications', () => { cy.gcy('assignee-search-select-popover') .contains('Organization member') .click(); + cy.gcy('assignee-search-select-popover') + .contains('Tasks test user') + .click(); dismissMenu(); cy.gcy('create-task-submit').click(); @@ -47,6 +50,12 @@ describe('tasks notifications', () => { .should('be.visible') .findDcy('task-label-name') .should('contain', 'New review task'); + dismissMenu(); + + cy.gcy('notifications-button').click(); + cy.gcy('notifications-list').contains('New review task').click(); + + cy.url().should('include', '/translations?task='); }); it('sends email to new assignee', () => { diff --git a/e2e/cypress/support/dataCyType.d.ts b/e2e/cypress/support/dataCyType.d.ts index 599f95b095..0956e63879 100644 --- a/e2e/cypress/support/dataCyType.d.ts +++ b/e2e/cypress/support/dataCyType.d.ts @@ -346,6 +346,8 @@ declare namespace DataCy { "namespaces-select-text-field" | "namespaces-selector" | "navigation-item" | + "notifications-button" | + "notifications-list" | "order-translation-confirmation" | "order-translation-confirmation-ok" | "order-translation-invitation-checkbox" | diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/component/TaskNotificationEnhancer.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/component/TaskNotificationEnhancer.kt new file mode 100644 index 0000000000..17e445e187 --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/component/TaskNotificationEnhancer.kt @@ -0,0 +1,25 @@ +package io.tolgee.ee.component + +import io.tolgee.ee.api.v2.hateoas.assemblers.TaskModelAssembler +import io.tolgee.ee.service.TaskService +import io.tolgee.hateoas.notification.NotificationEnhancer +import io.tolgee.hateoas.notification.NotificationModel +import io.tolgee.model.Notification +import org.springframework.stereotype.Component + +@Component +class TaskNotificationEnhancer( + private val taskService: TaskService, + private val taskModelAssembler: TaskModelAssembler, +) : NotificationEnhancer { + override fun enhanceNotifications(notifications: Map) { + val taskIds = notifications.mapNotNull { (source, _) -> source.linkedTask?.id } + val convertedTasks = taskService.getTasksWithScope(taskIds) + + notifications.forEach { (source, target) -> + target.linkedTask = + convertedTasks[source.linkedTask?.id] + ?.let { taskModelAssembler.toModel(it) } + } + } +} diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/AssigneeNotificationService.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/AssigneeNotificationService.kt index 56cf668675..bdb812e648 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/AssigneeNotificationService.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/AssigneeNotificationService.kt @@ -4,9 +4,11 @@ import io.sentry.Sentry import io.tolgee.component.FrontendUrlProvider import io.tolgee.component.email.TolgeeEmailSender import io.tolgee.dtos.misc.EmailParams +import io.tolgee.model.Notification import io.tolgee.model.UserAccount import io.tolgee.model.enums.TaskType import io.tolgee.model.task.Task +import io.tolgee.service.notification.NotificationService import io.tolgee.util.Logging import io.tolgee.util.logger import org.springframework.stereotype.Component @@ -15,11 +17,20 @@ import org.springframework.stereotype.Component class AssigneeNotificationService( private val tolgeeEmailSender: TolgeeEmailSender, private val frontendUrlProvider: FrontendUrlProvider, + private val notificationService: NotificationService, ) : Logging { fun notifyNewAssignee( user: UserAccount, task: Task, ) { + notificationService.save( + Notification().apply { + this.user = user + this.linkedTask = task + this.project = task.project + this.originatingUser = task.author + }, + ) val taskUrl = getTaskUrl(task.project.id, task.number) val myTasksUrl = getMyTasksUrl() diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/TaskService.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/TaskService.kt index 17ad905d26..901fd39109 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/TaskService.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/TaskService.kt @@ -468,6 +468,15 @@ class TaskService( } } + fun getTasksWithScope(taskIds: List): Map { + val tasks = taskRepository.findAllById(taskIds) + val taskIdsWithProjectIdAndNumber = tasks.associate { (it.project.id to it.number) to it.id } + val tasksWithScope = getTasksWithScope(tasks) + return tasksWithScope.associateBy { + taskIdsWithProjectIdAndNumber[it.project.id to it.number] ?: throw IllegalStateException("Item not found") + } + } + private fun getTasksWithScope(tasks: Collection): List { val scopes = taskRepository.getTasksScopes(tasks) return tasks.map { task -> diff --git a/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/task/TaskControllerTest.kt b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/task/TaskControllerTest.kt index 2a7e7bcea0..d1b0ef3c2c 100644 --- a/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/task/TaskControllerTest.kt +++ b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/task/TaskControllerTest.kt @@ -55,7 +55,7 @@ class TaskControllerTest : ProjectAuthControllerTest("/v2/projects/") { @Test @ProjectJWTAuthTestMethod - fun `creates new task`() { + fun `creates new task which triggers notification`() { val keys = testData.keysOutOfTask.map { it.self.id }.toMutableSet() performProjectAuthPost( "tasks", @@ -66,14 +66,14 @@ class TaskControllerTest : ProjectAuthControllerTest("/v2/projects/") { languageId = testData.englishLanguage.id, assignees = mutableSetOf( - testData.orgMember.self.id, + testData.user.id, ), keys = keys, ), ).andAssertThatJson { node("number").isNumber node("name").isEqualTo("Another task") - node("assignees[0].name").isEqualTo(testData.orgMember.self.name) + node("assignees[0].name").isEqualTo(testData.user.name) node("language.tag").isEqualTo(testData.englishLanguage.tag) node("totalItems").isEqualTo(keys.size) } @@ -81,6 +81,10 @@ class TaskControllerTest : ProjectAuthControllerTest("/v2/projects/") { performProjectAuthGet("tasks").andAssertThatJson { node("page.totalElements").isNumber.isEqualTo(BigDecimal(3)) } + + performAuthGet("/v2/notifications").andAssertThatJson { + node("_embedded.notificationModelList[0].linkedTask.name").isEqualTo("Another task") + } } @Test diff --git a/webapp/src/component/layout/TopBar/Notifications.tsx b/webapp/src/component/layout/TopBar/Notifications.tsx new file mode 100644 index 0000000000..5417cb5015 --- /dev/null +++ b/webapp/src/component/layout/TopBar/Notifications.tsx @@ -0,0 +1,124 @@ +import { default as React, FunctionComponent, useState } from 'react'; +import { + IconButton, + List, + ListItem, + ListItemButton, + styled, +} from '@mui/material'; +import Menu from '@mui/material/Menu'; +import { useHistory } from 'react-router-dom'; +import { useApiQuery } from 'tg.service/http/useQueryApi'; +import { Bell01 } from '@untitled-ui/icons-react'; +import { T } from '@tolgee/react'; + +const StyledMenu = styled(Menu)` + .MuiPaper-root { + margin-top: 5px; + } +`; + +const StyledIconButton = styled(IconButton)` + width: 40px; + height: 40px; + + img { + user-drag: none; + } +`; + +const ListItemHeader = styled(ListItem)` + font-weight: bold; +`; + +export const Notifications: FunctionComponent<{ className?: string }> = () => { + const handleOpen = (event: React.MouseEvent) => { + // @ts-ignore + setAnchorEl(event.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(null); + }; + + const [anchorEl, setAnchorEl] = useState(null); + + const history = useHistory(); + + const notifications = useApiQuery({ + url: '/v2/notifications', + method: 'get', + query: { size: 10000 }, + }).data; + + const notificationsData = notifications?._embedded?.notificationModelList; + + return ( + <> + + + + + + + + + {notificationsData?.map((notification, i) => { + const destinationUrl = `/projects/${notification.project?.id}/task?number=${notification.linkedTask?.number}`; + return ( + { + event.preventDefault(); + handleClose(); + history.push(destinationUrl); + }} + > + + + ); + })} + {notifications?.page?.totalElements === 0 && ( + + + + )} + + + + ); +}; diff --git a/webapp/src/component/layout/TopBar/TopBar.tsx b/webapp/src/component/layout/TopBar/TopBar.tsx index 9aa38d1707..f232c2d808 100644 --- a/webapp/src/component/layout/TopBar/TopBar.tsx +++ b/webapp/src/component/layout/TopBar/TopBar.tsx @@ -12,6 +12,7 @@ import { UserMenu } from '../../security/UserMenu/UserMenu'; import { AdminInfo } from './AdminInfo'; import { QuickStartTopBarButton } from '../QuickStartGuide/QuickStartTopBarButton'; import { LanguageMenu } from 'tg.component/layout/TopBar/LanguageMenu'; +import { Notifications } from 'tg.component/layout/TopBar/Notifications'; export const TOP_BAR_HEIGHT = 52; @@ -121,6 +122,7 @@ export const TopBar: React.FC = ({ debuggingCustomerAccount={isDebuggingCustomerAccount} /> + {user && } {!hideQuickStart && } {!user && } {user && } diff --git a/webapp/src/service/apiSchema.generated.ts b/webapp/src/service/apiSchema.generated.ts index 92dd27fe93..0028c43afd 100644 --- a/webapp/src/service/apiSchema.generated.ts +++ b/webapp/src/service/apiSchema.generated.ts @@ -823,6 +823,9 @@ export interface paths { /** Returns all organization projects the user has access to */ get: operations["getAllProjects_1"]; }; + "/v2/notifications": { + get: operations["getNotifications"]; + }; "/v2/invitations/{code}/accept": { get: operations["acceptInvitation"]; }; @@ -4803,6 +4806,18 @@ export interface components { projectsWithDirectPermission: components["schemas"]["SimpleProjectModel"][]; avatar?: components["schemas"]["Avatar"]; }; + NotificationModel: { + /** Format: int64 */ + id: number; + project?: components["schemas"]["SimpleProjectModel"]; + linkedTask?: components["schemas"]["TaskModel"]; + }; + PagedModelNotificationModel: { + _embedded?: { + notificationModelList?: components["schemas"]["NotificationModel"][]; + }; + page?: components["schemas"]["PageMetadata"]; + }; ApiKeyWithLanguagesModel: { /** * @deprecated @@ -18053,6 +18068,58 @@ export interface operations { }; }; }; + getNotifications: { + parameters: { + query: { + /** Zero-based page index (0..N) */ + page?: number; + /** The size of the page to be returned */ + size?: number; + /** Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported. */ + sort?: string[]; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["PagedModelNotificationModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + }; acceptInvitation: { parameters: { path: { From ed1e6ed8eb28a531f1ddd4828074c71b40408b41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Standa=20Nov=C3=A1k?= Date: Tue, 14 Jan 2025 16:51:52 +0100 Subject: [PATCH 2/3] feat: Notifications badge (#2840) --- e2e/cypress/e2e/tasks/tasksNotifications.cy.ts | 3 +++ e2e/cypress/support/dataCyType.d.ts | 1 + webapp/src/component/layout/TopBar/Notifications.tsx | 9 ++++++++- 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/e2e/cypress/e2e/tasks/tasksNotifications.cy.ts b/e2e/cypress/e2e/tasks/tasksNotifications.cy.ts index af556bf4ca..4b36ef4438 100644 --- a/e2e/cypress/e2e/tasks/tasksNotifications.cy.ts +++ b/e2e/cypress/e2e/tasks/tasksNotifications.cy.ts @@ -52,6 +52,9 @@ describe('tasks notifications', () => { .should('contain', 'New review task'); dismissMenu(); + cy.gcy('notifications-count') + .find('.MuiBadge-badge') + .should('have.text', '1'); cy.gcy('notifications-button').click(); cy.gcy('notifications-list').contains('New review task').click(); diff --git a/e2e/cypress/support/dataCyType.d.ts b/e2e/cypress/support/dataCyType.d.ts index 0956e63879..94cc8e01d2 100644 --- a/e2e/cypress/support/dataCyType.d.ts +++ b/e2e/cypress/support/dataCyType.d.ts @@ -347,6 +347,7 @@ declare namespace DataCy { "namespaces-selector" | "navigation-item" | "notifications-button" | + "notifications-count" | "notifications-list" | "order-translation-confirmation" | "order-translation-confirmation-ok" | diff --git a/webapp/src/component/layout/TopBar/Notifications.tsx b/webapp/src/component/layout/TopBar/Notifications.tsx index 5417cb5015..e939b5e52e 100644 --- a/webapp/src/component/layout/TopBar/Notifications.tsx +++ b/webapp/src/component/layout/TopBar/Notifications.tsx @@ -1,5 +1,6 @@ import { default as React, FunctionComponent, useState } from 'react'; import { + Badge, IconButton, List, ListItem, @@ -63,7 +64,13 @@ export const Notifications: FunctionComponent<{ className?: string }> = () => { onClick={handleOpen} size="large" > - + + + Date: Wed, 15 Jan 2025 21:54:07 +0100 Subject: [PATCH 3/3] feat: Notifications real-time update with websockets (#2843) --- .../io/tolgee/batch/BatchJobTestUtil.kt | 1 + .../tolgee/websocket/AbstractWebsocketTest.kt | 109 +++++++++++++----- .../tolgee/websocket/WebsocketTestHelper.kt | 20 +++- .../events/OnNotificationsChangedForUser.kt | 5 + .../notification/NotificationService.kt | 19 +++ .../io/tolgee/websocket/WebsocketEvent.kt | 10 +- .../io/tolgee/websocket/WebsocketEventType.kt | 3 +- .../e2e/tasks/tasksNotifications.cy.ts | 8 +- .../component/layout/TopBar/Notifications.tsx | 25 +++- .../src/views/projects/WebsocketPreview.tsx | 12 +- .../src/websocket-client/WebsocketClient.ts | 11 +- 11 files changed, 177 insertions(+), 46 deletions(-) create mode 100644 backend/data/src/main/kotlin/io/tolgee/events/OnNotificationsChangedForUser.kt diff --git a/backend/app/src/test/kotlin/io/tolgee/batch/BatchJobTestUtil.kt b/backend/app/src/test/kotlin/io/tolgee/batch/BatchJobTestUtil.kt index 337655bbff..badd8a24c0 100644 --- a/backend/app/src/test/kotlin/io/tolgee/batch/BatchJobTestUtil.kt +++ b/backend/app/src/test/kotlin/io/tolgee/batch/BatchJobTestUtil.kt @@ -263,6 +263,7 @@ class BatchJobTestUtil( port, jwtService.emitToken(testData.user.id), testData.projectBuilder.self.id, + testData.user.id, ) websocketHelper.listenForBatchJobProgress() } diff --git a/backend/app/src/test/kotlin/io/tolgee/websocket/AbstractWebsocketTest.kt b/backend/app/src/test/kotlin/io/tolgee/websocket/AbstractWebsocketTest.kt index a284f79b80..c4de13fd3c 100644 --- a/backend/app/src/test/kotlin/io/tolgee/websocket/AbstractWebsocketTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/websocket/AbstractWebsocketTest.kt @@ -5,17 +5,18 @@ import io.tolgee.development.testDataBuilder.data.BaseTestData import io.tolgee.fixtures.andIsOk import io.tolgee.fixtures.isValidId import io.tolgee.fixtures.node +import io.tolgee.fixtures.waitFor +import io.tolgee.model.Notification import io.tolgee.model.UserAccount import io.tolgee.model.key.Key import io.tolgee.model.translation.Translation +import io.tolgee.service.notification.NotificationService import io.tolgee.testing.WebsocketTest import io.tolgee.testing.annotations.ProjectJWTAuthTestMethod import io.tolgee.testing.assert import net.javacrumbs.jsonunit.assertj.assertThatJson -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.api.* +import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.web.server.LocalServerPort @TestInstance(TestInstance.Lifecycle.PER_CLASS) @@ -24,8 +25,12 @@ abstract class AbstractWebsocketTest : ProjectAuthControllerTest("/v2/projects/" lateinit var testData: BaseTestData lateinit var translation: Translation lateinit var key: Key - lateinit var notPermittedUser: UserAccount - lateinit var helper: WebsocketTestHelper + lateinit var anotherUser: UserAccount + lateinit var currentUserWebsocket: WebsocketTestHelper + lateinit var anotherUserWebsocket: WebsocketTestHelper + + @Autowired + lateinit var notificationService: NotificationService @LocalServerPort private val port: Int? = null @@ -33,24 +38,32 @@ abstract class AbstractWebsocketTest : ProjectAuthControllerTest("/v2/projects/" @BeforeEach fun beforeEach() { prepareTestData() - helper = + currentUserWebsocket = WebsocketTestHelper( port, jwtService.emitToken(testData.user.id), testData.projectBuilder.self.id, + testData.user.id, + ) + anotherUserWebsocket = + WebsocketTestHelper( + port, + jwtService.emitToken(anotherUser.id), + testData.projectBuilder.self.id, + anotherUser.id, ) - helper.listenForTranslationDataModified() } @AfterEach fun after() { - helper.stop() + currentUserWebsocket.stop() } @Test @ProjectJWTAuthTestMethod fun `notifies on key modification`() { - helper.assertNotified( + currentUserWebsocket.listenForTranslationDataModified() + currentUserWebsocket.assertNotified( { performProjectAuthPut("keys/${key.id}", mapOf("name" to "name edited")) }, @@ -85,7 +98,8 @@ abstract class AbstractWebsocketTest : ProjectAuthControllerTest("/v2/projects/" @Test @ProjectJWTAuthTestMethod fun `notifies on key deletion`() { - helper.assertNotified( + currentUserWebsocket.listenForTranslationDataModified() + currentUserWebsocket.assertNotified( { performProjectAuthDelete("keys/${key.id}") }, @@ -113,7 +127,8 @@ abstract class AbstractWebsocketTest : ProjectAuthControllerTest("/v2/projects/" @Test @ProjectJWTAuthTestMethod fun `notifies on key creation`() { - helper.assertNotified( + currentUserWebsocket.listenForTranslationDataModified() + currentUserWebsocket.assertNotified( { performProjectAuthPost("keys", mapOf("name" to "new key")) }, @@ -141,7 +156,8 @@ abstract class AbstractWebsocketTest : ProjectAuthControllerTest("/v2/projects/" @Test @ProjectJWTAuthTestMethod fun `notifies on translation modification`() { - helper.assertNotified( + currentUserWebsocket.listenForTranslationDataModified() + currentUserWebsocket.assertNotified( { performProjectAuthPut( "translations", @@ -184,6 +200,25 @@ abstract class AbstractWebsocketTest : ProjectAuthControllerTest("/v2/projects/" } } + @Test + @ProjectJWTAuthTestMethod + fun `notifies user on change of his notifications`() { + currentUserWebsocket.listenForNotificationsChanged() + anotherUserWebsocket.listenForNotificationsChanged() + + currentUserWebsocket.assertNotified( + { + saveNotificationForCurrentUser() + }, + ) { + assertThatJson(it.poll()).apply { + node("timestamp").isNotNull + } + } + + anotherUserWebsocket.receivedMessages.assert.isEmpty() + } + /** * The request is made by permitted user, but user without permission tries to listen, so they shell * not be notified @@ -191,13 +226,8 @@ abstract class AbstractWebsocketTest : ProjectAuthControllerTest("/v2/projects/" @Test @ProjectJWTAuthTestMethod fun `doesn't subscribe without permissions`() { - val notPermittedSubscriptionHelper = - WebsocketTestHelper( - port, - jwtService.emitToken(notPermittedUser.id), - testData.projectBuilder.self.id, - ) - notPermittedSubscriptionHelper.listenForTranslationDataModified() + currentUserWebsocket.listenForTranslationDataModified() + anotherUserWebsocket.listenForTranslationDataModified() performProjectAuthPut( "translations", mapOf( @@ -205,18 +235,45 @@ abstract class AbstractWebsocketTest : ProjectAuthControllerTest("/v2/projects/" "translations" to mapOf("en" to "haha"), ), ).andIsOk - Thread.sleep(1000) - notPermittedSubscriptionHelper.receivedMessages.assert.isEmpty() - // but authorized user received the message - helper.receivedMessages.assert.isNotEmpty + assertCurrentUserReceivedMessage() + anotherUserWebsocket.receivedMessages.assert.isEmpty() + } + + @Test + @ProjectJWTAuthTestMethod + fun `doesn't subscribe as another user`() { + currentUserWebsocket.listenForNotificationsChanged() + val spyingUserWebsocket = + WebsocketTestHelper( + port, + jwtService.emitToken(anotherUser.id), + testData.projectBuilder.self.id, + // anotherUser trying to spy on other user's websocket + testData.user.id, + ) + spyingUserWebsocket.listenForNotificationsChanged() + saveNotificationForCurrentUser() + + assertCurrentUserReceivedMessage() + spyingUserWebsocket.receivedMessages.assert.isEmpty() + } + + private fun assertCurrentUserReceivedMessage() { + waitFor { currentUserWebsocket.receivedMessages.isNotEmpty() } + } + + private fun saveNotificationForCurrentUser() { + executeInNewTransaction { + notificationService.save(Notification().apply { user = testData.user }) + } } private fun prepareTestData() { testData = BaseTestData() testData.root.addUserAccount { - username = "franta" - notPermittedUser = this + username = "anotherUser" + anotherUser = this } testData.projectBuilder.apply { addKey { diff --git a/backend/app/src/test/kotlin/io/tolgee/websocket/WebsocketTestHelper.kt b/backend/app/src/test/kotlin/io/tolgee/websocket/WebsocketTestHelper.kt index c622bc7e41..7a64141626 100644 --- a/backend/app/src/test/kotlin/io/tolgee/websocket/WebsocketTestHelper.kt +++ b/backend/app/src/test/kotlin/io/tolgee/websocket/WebsocketTestHelper.kt @@ -18,7 +18,7 @@ import java.lang.reflect.Type import java.util.concurrent.LinkedBlockingDeque import java.util.concurrent.TimeUnit -class WebsocketTestHelper(val port: Int?, val jwtToken: String, val projectId: Long) : Logging { +class WebsocketTestHelper(val port: Int?, val jwtToken: String, val projectId: Long, val userId: Long) : Logging { private var sessionHandler: MySessionHandler? = null lateinit var receivedMessages: LinkedBlockingDeque @@ -30,6 +30,10 @@ class WebsocketTestHelper(val port: Int?, val jwtToken: String, val projectId: L listen("/projects/$projectId/${WebsocketEventType.BATCH_JOB_PROGRESS.typeName}") } + fun listenForNotificationsChanged() { + listen("/users/$userId/${WebsocketEventType.NOTIFICATIONS_CHANGED.typeName}") + } + private val webSocketStompClient by lazy { WebSocketStompClient( SockJsClient(listOf(WebSocketTransport(StandardWebSocketClient()))), @@ -105,9 +109,19 @@ class WebsocketTestHelper(val port: Int?, val jwtToken: String, val projectId: L stompHeaders: StompHeaders, o: Any?, ) { - logger.info("Handle Frame with payload: {}", (o as? ByteArray)?.decodeToString()) + logger.info( + "Handle Frame with stompHeaders: '{}' and payload: '{}'", + stompHeaders, + (o as? ByteArray)?.decodeToString(), + ) + + if (o !is ByteArray) { + logger.info("Payload '{}' is not a ByteArray, not adding into received messages.", o) + return + } + try { - receivedMessages.add((o as ByteArray).decodeToString()) + receivedMessages.add(o.decodeToString()) } catch (e: InterruptedException) { throw RuntimeException(e) } diff --git a/backend/data/src/main/kotlin/io/tolgee/events/OnNotificationsChangedForUser.kt b/backend/data/src/main/kotlin/io/tolgee/events/OnNotificationsChangedForUser.kt new file mode 100644 index 0000000000..46eb9bfefa --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/events/OnNotificationsChangedForUser.kt @@ -0,0 +1,5 @@ +package io.tolgee.events + +class OnNotificationsChangedForUser( + val userId: Long, +) diff --git a/backend/data/src/main/kotlin/io/tolgee/service/notification/NotificationService.kt b/backend/data/src/main/kotlin/io/tolgee/service/notification/NotificationService.kt index 49c9df863b..0488416184 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/notification/NotificationService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/notification/NotificationService.kt @@ -1,14 +1,22 @@ package io.tolgee.service.notification +import io.tolgee.component.CurrentDateProvider +import io.tolgee.events.OnNotificationsChangedForUser import io.tolgee.model.Notification import io.tolgee.repository.NotificationRepository +import io.tolgee.websocket.* +import org.springframework.context.ApplicationEventPublisher import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable import org.springframework.stereotype.Service +import org.springframework.transaction.event.TransactionalEventListener @Service class NotificationService( private val notificationRepository: NotificationRepository, + private val websocketEventPublisher: WebsocketEventPublisher, + private val currentDateProvider: CurrentDateProvider, + private val applicationEventPublisher: ApplicationEventPublisher, ) { fun getNotifications( userId: Long, @@ -19,5 +27,16 @@ class NotificationService( fun save(notification: Notification) { notificationRepository.save(notification) + applicationEventPublisher.publishEvent(OnNotificationsChangedForUser(notification.user.id)) + } + + @TransactionalEventListener + fun onNotificationSaved(event: OnNotificationsChangedForUser) { + websocketEventPublisher( + "/users/${event.userId}/${WebsocketEventType.NOTIFICATIONS_CHANGED.typeName}", + WebsocketEvent( + timestamp = currentDateProvider.date.time, + ), + ) } } diff --git a/backend/data/src/main/kotlin/io/tolgee/websocket/WebsocketEvent.kt b/backend/data/src/main/kotlin/io/tolgee/websocket/WebsocketEvent.kt index e0f7e6e70b..c5ca62c386 100644 --- a/backend/data/src/main/kotlin/io/tolgee/websocket/WebsocketEvent.kt +++ b/backend/data/src/main/kotlin/io/tolgee/websocket/WebsocketEvent.kt @@ -3,10 +3,10 @@ package io.tolgee.websocket import io.tolgee.activity.data.ActivityType data class WebsocketEvent( - val actor: ActorInfo, + val actor: ActorInfo? = null, val data: Any? = null, - val sourceActivity: ActivityType?, - val activityId: Long?, - val dataCollapsed: Boolean, - val timestamp: Long, + val sourceActivity: ActivityType? = null, + val activityId: Long? = null, + val dataCollapsed: Boolean = false, + val timestamp: Long = System.currentTimeMillis(), ) diff --git a/backend/data/src/main/kotlin/io/tolgee/websocket/WebsocketEventType.kt b/backend/data/src/main/kotlin/io/tolgee/websocket/WebsocketEventType.kt index 59dbe78be7..3e2e445a52 100644 --- a/backend/data/src/main/kotlin/io/tolgee/websocket/WebsocketEventType.kt +++ b/backend/data/src/main/kotlin/io/tolgee/websocket/WebsocketEventType.kt @@ -1,8 +1,9 @@ package io.tolgee.websocket -enum class WebsocketEventType() { +enum class WebsocketEventType { TRANSLATION_DATA_MODIFIED, BATCH_JOB_PROGRESS, + NOTIFICATIONS_CHANGED, ; val typeName get() = name.lowercase().replace("_", "-") diff --git a/e2e/cypress/e2e/tasks/tasksNotifications.cy.ts b/e2e/cypress/e2e/tasks/tasksNotifications.cy.ts index 4b36ef4438..18f13a9109 100644 --- a/e2e/cypress/e2e/tasks/tasksNotifications.cy.ts +++ b/e2e/cypress/e2e/tasks/tasksNotifications.cy.ts @@ -42,6 +42,10 @@ describe('tasks notifications', () => { assertMessage('1 task created'); + cy.gcy('notifications-count') + .find('.MuiBadge-badge') + .should('have.text', '1'); + getAssignedEmailNotification().then(({ taskLink, toAddress }) => { assert(toAddress === 'organization.member@test.com', 'correct recipient'); cy.visit(taskLink); @@ -51,10 +55,6 @@ describe('tasks notifications', () => { .findDcy('task-label-name') .should('contain', 'New review task'); dismissMenu(); - - cy.gcy('notifications-count') - .find('.MuiBadge-badge') - .should('have.text', '1'); cy.gcy('notifications-button').click(); cy.gcy('notifications-list').contains('New review task').click(); diff --git a/webapp/src/component/layout/TopBar/Notifications.tsx b/webapp/src/component/layout/TopBar/Notifications.tsx index e939b5e52e..a867053239 100644 --- a/webapp/src/component/layout/TopBar/Notifications.tsx +++ b/webapp/src/component/layout/TopBar/Notifications.tsx @@ -1,4 +1,9 @@ -import { default as React, FunctionComponent, useState } from 'react'; +import { + default as React, + FunctionComponent, + useEffect, + useState, +} from 'react'; import { Badge, IconButton, @@ -12,6 +17,8 @@ import { useHistory } from 'react-router-dom'; import { useApiQuery } from 'tg.service/http/useQueryApi'; import { Bell01 } from '@untitled-ui/icons-react'; import { T } from '@tolgee/react'; +import { useGlobalContext } from 'tg.globalContext/GlobalContext'; +import { useUser } from 'tg.globalContext/helpers'; const StyledMenu = styled(Menu)` .MuiPaper-root { @@ -46,12 +53,24 @@ export const Notifications: FunctionComponent<{ className?: string }> = () => { const history = useHistory(); - const notifications = useApiQuery({ + const client = useGlobalContext((c) => c.wsClient.client); + const user = useUser(); + + const notificationsLoadable = useApiQuery({ url: '/v2/notifications', method: 'get', query: { size: 10000 }, - }).data; + }); + + useEffect(() => { + if (client && user) { + return client.subscribe(`/users/${user.id}/notifications-changed`, () => + notificationsLoadable.refetch({ cancelRefetch: true }) + ); + } + }, [user, client]); + const notifications = notificationsLoadable.data; const notificationsData = notifications?._embedded?.notificationModelList; return ( diff --git a/webapp/src/views/projects/WebsocketPreview.tsx b/webapp/src/views/projects/WebsocketPreview.tsx index 015f85e546..ee021feee4 100644 --- a/webapp/src/views/projects/WebsocketPreview.tsx +++ b/webapp/src/views/projects/WebsocketPreview.tsx @@ -1,4 +1,4 @@ -import { useConfig } from 'tg.globalContext/helpers'; +import { useConfig, useUser } from 'tg.globalContext/helpers'; import { useProject } from 'tg.hooks/useProject'; import { useEffect, useState } from 'react'; import { BaseView } from 'tg.component/layout/BaseView'; @@ -9,6 +9,7 @@ export const WebsocketPreview = () => { const project = useProject(); const jwtToken = useGlobalContext((c) => c.auth.jwtToken); const client = useGlobalContext((c) => c.wsClient.client); + const user = useUser(); useEffect(() => { if (client) { @@ -19,6 +20,15 @@ export const WebsocketPreview = () => { } }, [config, project, jwtToken, client]); + useEffect(() => { + if (client && user) { + return client.subscribe( + `/users/${user.id}/notifications-changed`, + (data) => addMessage(JSON.stringify(data, undefined, 2)) + ); + } + }, [config, user, jwtToken, client]); + const [messages, setMessages] = useState([] as string[]); const addMessage = (message: string) => { diff --git a/webapp/src/websocket-client/WebsocketClient.ts b/webapp/src/websocket-client/WebsocketClient.ts index d438eb0697..fa906b5031 100644 --- a/webapp/src/websocket-client/WebsocketClient.ts +++ b/webapp/src/websocket-client/WebsocketClient.ts @@ -110,7 +110,7 @@ export const WebsocketClient = (options: TranslationsClientOptions) => { * @param callback Callback function to be executed when event is triggered * @return Function Function unsubscribing the event listening */ - function subscribe( + function subscribe( channel: T, callback: (data: Data) => void ): () => void { @@ -138,8 +138,13 @@ export const WebsocketClient = (options: TranslationsClientOptions) => { return Object.freeze({ subscribe, disconnect }); }; -export type EventType = 'translation-data-modified' | 'batch-job-progress'; -export type Channel = `/projects/${number}/${EventType}`; +export type EventTypeProject = + | 'translation-data-modified' + | 'batch-job-progress'; +export type ChannelProject = `/projects/${number}/${EventTypeProject}`; + +export type EventTypeUser = 'notifications-changed'; +export type ChannelUser = `/users/${number}/${EventTypeUser}`; export type TranslationsModifiedData = WebsocketEvent<{ translations: EntityModification<'translation'>[] | null;