Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Notifications MVP #2813

Merged
merged 26 commits into from
Jan 14, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
bff9f7f
FE: Basic notifications dropdown
StaNov Jan 3, 2025
6bb4a37
FE + BE: Notifications getting fetched from BE
StaNov Jan 3, 2025
39c4e7b
BE: Fetching notifications from database
StaNov Jan 6, 2025
410e945
BE: Creating notifications on task assignee updated
StaNov Jan 7, 2025
67e21e3
BE: Move stuff around to the correct places
StaNov Jan 7, 2025
874bada
Prettier
StaNov Jan 7, 2025
123807e
ktlint
StaNov Jan 7, 2025
4c23e16
Notification derives from StandardAuditModel
StaNov Jan 7, 2025
e77626a
linkedProjectId -> projectId
StaNov Jan 7, 2025
93f8739
Schema updated
StaNov Jan 7, 2025
1197050
BE: Move creating notifications on task assignee updated to AssigneeN…
StaNov Jan 7, 2025
3848eb1
API task model
StaNov Jan 8, 2025
d4086b8
Fetching project instead of just ID
StaNov Jan 8, 2025
67393e1
ktlint
StaNov Jan 8, 2025
d8e776f
Change collection of pairs to map
StaNov Jan 9, 2025
75d89d3
Make link from notification copiable
StaNov Jan 8, 2025
08cb0a4
Empty notifications fix
StaNov Jan 9, 2025
9313757
Cypress identifiers
StaNov Jan 9, 2025
7270db9
Notifications header
StaNov Jan 9, 2025
5c462d7
Cypress tests
StaNov Jan 9, 2025
03bdad4
Test for notification fetching
StaNov Jan 9, 2025
73ecb4e
Test for notification triggering while creating a task
StaNov Jan 9, 2025
9a0458c
FE: Scrollable notifications
StaNov Jan 13, 2025
072ef7b
Review: Save renamed
StaNov Jan 14, 2025
2db986d
Review: Not fetching project eagerly
StaNov Jan 14, 2025
adb5b20
Review: Handling tasks by ID instead of project ID and task number
StaNov Jan 14, 2025
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
@@ -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 = [email protected]
this.project = [email protected]
this.linkedTask = task.self
this.originatingUser = [email protected]
}

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
Loading