Skip to content

Commit

Permalink
feat: Notifications MVP (#2813)
Browse files Browse the repository at this point in the history
Partially implements #2577.
  • Loading branch information
StaNov committed Jan 14, 2025
1 parent 38df038 commit ad6ed9c
Show file tree
Hide file tree
Showing 24 changed files with 592 additions and 13 deletions.
Original file line number Diff line number Diff line change
@@ -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<NotificationEnhancer>,
private val pagedResourcesAssembler: PagedResourcesAssembler<Notification>,
) {
@GetMapping
@Operation(summary = "Gets notifications of the currently logged in user, newest is first.")
@AllowApiAccess
fun getNotifications(
@ParameterObject pageable: Pageable,
): PagedModel<NotificationModel> {
val notifications = notificationService.getNotifications(authenticationFacade.authenticatedUser.id, pageable)
return pagedResourcesAssembler.toModel(notifications, NotificationModelAssembler(enhancers, notifications))
}
}
Original file line number Diff line number Diff line change
@@ -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<Notification, NotificationModel>)
}
Original file line number Diff line number Diff line change
@@ -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<NotificationModel>(), Serializable
Original file line number Diff line number Diff line change
@@ -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<NotificationEnhancer>,
private val notifications: Page<Notification>,
) : RepresentationModelAssemblerSupport<Notification, NotificationModel>(
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")
}
}
Original file line number Diff line number Diff line change
@@ -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<Notification, NotificationModel>) {
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) }
}
}
}
Original file line number Diff line number Diff line change
@@ -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")
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -114,6 +108,7 @@ class TestDataService(

executeInNewTransaction(transactionManager) {
saveProjectData(builder)
saveNotifications(builder)
finalize()
}

Expand Down Expand Up @@ -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<UserPreferencesBuilder>) {
data.forEach { userPreferencesService.save(it.self) }
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Notification, NotificationBuilder> {
override var self: Notification =
Notification().apply {
this.user = userAccountBuilder.self
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -20,6 +21,7 @@ class UserAccountBuilder(
var userPreferences: UserPreferencesBuilder? = null
var pats: MutableList<PatBuilder> = mutableListOf()
var slackUserConnections: MutableList<SlackUserConnectionBuilder> = mutableListOf()
var notifications: MutableList<NotificationBuilder> = mutableListOf()
}

var data = DATA()
Expand All @@ -37,4 +39,6 @@ class UserAccountBuilder(
fun addPat(ft: FT<Pat>) = addOperation(data.pats, ft)

fun addSlackUserConnection(ft: FT<SlackUserConnection>) = addOperation(data.slackUserConnections, ft)

fun addNotification(ft: FT<Notification>) = addOperation(data.notifications, ft)
}
Original file line number Diff line number Diff line change
@@ -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
}
24 changes: 24 additions & 0 deletions backend/data/src/main/kotlin/io/tolgee/model/Notification.kt
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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<Notification, Long> {
@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<Notification>
}
Original file line number Diff line number Diff line change
@@ -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<Notification> {
return notificationRepository.fetchNotificationsByUserId(userId, pageable)
}

fun save(notification: Notification) {
notificationRepository.save(notification)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,10 @@ class ProjectService(
return projectRepository.find(id)
}

fun findAll(ids: List<Long>): List<Project> {
return projectRepository.findAllById(ids)
}

@Transactional
fun getView(id: Long): ProjectWithLanguagesView {
val perms = permissionService.getProjectPermissionData(id, authenticationFacade.authenticatedUser.id)
Expand Down
Loading

0 comments on commit ad6ed9c

Please sign in to comment.