Skip to content
Merged
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
3 changes: 3 additions & 0 deletions backend/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ extra["commons-lang3.version"] = "3.18.0"
// logback-core 1.5.34 -> 1.5.35 fixes CVE (object injection via HardenedObjectInputStream, Dependabot #79).
// Spring Boot 3.5.16 BOM pins 1.5.34; override the shared property so logback-core AND logback-classic move together.
extra["logback.version"] = "1.5.35"
// postgresql 42.7.11 -> 42.7.12 fixes CVE-2026-54291 (HIGH). Spring Boot 3.5.16 BOM pins 42.7.11;
// override the managed property so the runtime JDBC driver picks up the patched release.
extra["postgresql.version"] = "42.7.12"

// Override Spring Boot BOM version for Testcontainers to support Docker Desktop 4.x on Windows
dependencyManagement {
Expand Down
2 changes: 1 addition & 1 deletion backend/gradle.lockfile
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ org.mockito:mockito-junit-jupiter:5.17.0=testCompileClasspath,testRuntimeClasspa
org.objenesis:objenesis:3.4=testCompileClasspath,testRuntimeClasspath
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
org.ow2.asm:asm:9.7.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.postgresql:postgresql:42.7.11=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath
org.postgresql:postgresql:42.7.12=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath
org.reactivestreams:reactive-streams:1.0.4=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.rnorth.duct-tape:duct-tape:1.0.8=testCompileClasspath,testRuntimeClasspath
org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,10 @@ class ActionExecutor(
notificationService.createDirect(
userId = recipientId,
type = NotificationType.AUTOMATION,
title = "Automation: ${issue.key}",
body = message,
link = "/p/${issue.project.key}/issues/${issue.key}"
titleKey = "notification.automation.title",
link = "/p/${issue.project.key}/issues/${issue.key}",
titleArgs = arrayOf(issue.key),
rawBody = message,
)
}
ActionType.CREATE_COMMENT -> {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.taskowolf.notifications.application

import com.taskowolf.comments.domain.events.MentionEvent
import com.taskowolf.core.infrastructure.LocalizedMessages
import com.taskowolf.issues.domain.events.IssueFieldChangedEvent
import com.taskowolf.notifications.domain.NotificationChannel
import com.taskowolf.notifications.domain.NotificationType
Expand All @@ -15,23 +16,24 @@ class EmailService(
private val preferences: NotificationPreferenceService,
private val mailSender: JavaMailSender,
@Value("\${spring.mail.host:}") private val mailHost: String,
@Value("\${taskowolf.smtp.from:TaskWolf <noreply@example.com>}") private val fromAddress: String
@Value("\${taskowolf.smtp.from:TaskWolf <noreply@example.com>}") private val fromAddress: String,
private val localizedMessages: LocalizedMessages
) {
private val enabled get() = mailHost.isNotBlank()

@EventListener
fun onMention(event: MentionEvent) {
if (!enabled) return
if (!preferences.isEnabled(event.mentionedUser.id, NotificationType.COMMENT_MENTION, NotificationChannel.EMAIL)) return
val locale = localizedMessages.localeOf(event.mentionedUser)
val msg = SimpleMailMessage().apply {
from = fromAddress
setTo(event.mentionedUser.email)
subject = "You were mentioned in ${event.issue.key}"
text = """
|${event.issue.key}: ${event.issue.title}
|
|${event.comment.body.take(500)}
""".trimMargin()
subject = localizedMessages.get("email.mention.subject", locale, event.issue.key)
text = localizedMessages.get(
"email.mention.body", locale,
event.issue.key, event.issue.title, event.comment.body.take(500)
)
}
mailSender.send(msg)
}
Expand All @@ -41,14 +43,12 @@ class EmailService(
if (!enabled || event.field != "assignee") return
val assignee = event.issue.assignee ?: return
if (!preferences.isEnabled(assignee.id, NotificationType.ISSUE_ASSIGNED, NotificationChannel.EMAIL)) return
val locale = localizedMessages.localeOf(assignee)
val msg = SimpleMailMessage().apply {
from = fromAddress
setTo(assignee.email)
subject = "You were assigned to ${event.issue.key}"
text = """
|You have been assigned to: ${event.issue.key}
|${event.issue.title}
""".trimMargin()
subject = localizedMessages.get("email.assigned.subject", locale, event.issue.key)
text = localizedMessages.get("email.assigned.body", locale, event.issue.key, event.issue.title)
}
mailSender.send(msg)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.taskowolf.notifications.application

import com.taskowolf.auth.infrastructure.UserRepository
import com.taskowolf.comments.domain.events.MentionEvent
import com.taskowolf.core.infrastructure.LocalizedMessages
import com.taskowolf.core.infrastructure.NotFoundException
import com.taskowolf.issues.domain.events.IssueFieldChangedEvent
import com.taskowolf.notifications.domain.Notification
Expand All @@ -12,23 +14,27 @@ import org.springframework.data.domain.Page
import org.springframework.data.domain.PageRequest
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.util.Locale
import java.util.UUID

@Service
class NotificationService(
private val repository: NotificationRepository,
private val preferences: NotificationPreferenceService
private val preferences: NotificationPreferenceService,
private val localizedMessages: LocalizedMessages,
private val userRepository: UserRepository
) {

@EventListener
@Transactional
fun onMention(event: MentionEvent) {
if (!preferences.isEnabled(event.mentionedUser.id, NotificationType.COMMENT_MENTION, NotificationChannel.IN_APP)) return
val locale = localizedMessages.localeOf(event.mentionedUser)
repository.save(
Notification(
userId = event.mentionedUser.id,
type = NotificationType.COMMENT_MENTION,
title = "You were mentioned in ${event.issue.key}",
title = localizedMessages.get("notification.mention.title", locale, event.issue.key),
body = event.comment.body.take(200),
link = "/issues/${event.issue.key}"
)
Expand All @@ -41,11 +47,12 @@ class NotificationService(
if (event.field != "assignee") return
val assignee = event.issue.assignee ?: return
if (!preferences.isEnabled(assignee.id, NotificationType.ISSUE_ASSIGNED, NotificationChannel.IN_APP)) return
val locale = localizedMessages.localeOf(assignee)
repository.save(
Notification(
userId = assignee.id,
type = NotificationType.ISSUE_ASSIGNED,
title = "You were assigned to ${event.issue.key}",
title = localizedMessages.get("notification.assigned.title", locale, event.issue.key),
body = event.issue.title,
link = "/issues/${event.issue.key}"
)
Expand All @@ -70,9 +77,27 @@ class NotificationService(
@Transactional(readOnly = true)
fun countUnread(userId: UUID): Long = repository.countByUserIdAndReadFalse(userId)

/**
* Creates a notification, rendering [titleKey] (and optional [bodyKey]) in the recipient's
* stored language. Pass [rawBody] for a user-content body that must not be translated; if
* neither [bodyKey] nor [rawBody] is set, the body is empty.
*/
@Transactional
fun createDirect(userId: UUID, type: NotificationType, title: String, body: String, link: String) {
fun createDirect(
userId: UUID,
type: NotificationType,
titleKey: String,
link: String,
titleArgs: Array<out Any?> = emptyArray(),
bodyKey: String? = null,
bodyArgs: Array<out Any?> = emptyArray(),
rawBody: String? = null,
) {
if (!preferences.isEnabled(userId, type, NotificationChannel.IN_APP)) return
val locale = userRepository.findById(userId)
.map(localizedMessages::localeOf).orElse(Locale.ENGLISH)
val title = localizedMessages.get(titleKey, locale, *titleArgs)
val body = if (bodyKey != null) localizedMessages.get(bodyKey, locale, *bodyArgs) else rawBody.orEmpty()
repository.save(Notification(userId = userId, type = type, title = title, body = body, link = link))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.taskowolf.servicedesk.application

import com.taskowolf.comments.domain.Comment
import com.taskowolf.comments.infrastructure.CommentRepository
import com.taskowolf.issues.infrastructure.IssueRepository
import com.taskowolf.notifications.application.NotificationService
import com.taskowolf.notifications.domain.NotificationType
import com.taskowolf.servicedesk.domain.Incident
Expand All @@ -16,7 +17,8 @@ import java.util.UUID
class IncidentService(
private val incidentRepo: IncidentRepository,
private val commentRepo: CommentRepository,
private val notificationService: NotificationService
private val notificationService: NotificationService,
private val issueRepository: IssueRepository
) {
companion object {
/** Null authorId used for system-generated comments (no FK violation since author_id is nullable). */
Expand All @@ -26,14 +28,19 @@ class IncidentService(
@Transactional
fun create(issueId: UUID, severity: IncidentSeverity, onCallAssigneeId: UUID?, notifyUserIds: List<UUID>): Incident {
val incident = incidentRepo.save(Incident(issueId = issueId, severity = severity, onCallAssigneeId = onCallAssigneeId))
notifyUserIds.forEach { uid ->
notificationService.createDirect(
userId = uid,
type = NotificationType.AUTOMATION,
title = "Incident declared: ${severity.name} on issue $issueId",
body = "A ${severity.name} incident has been declared for issue $issueId.",
link = "/issues/$issueId"
)
if (notifyUserIds.isNotEmpty()) {
val issueKey = issueRepository.findById(issueId).map { it.key }.orElse(issueId.toString())
notifyUserIds.forEach { uid ->
notificationService.createDirect(
userId = uid,
type = NotificationType.AUTOMATION,
titleKey = "notification.incident.title",
link = "/issues/$issueKey",
titleArgs = arrayOf(severity.name, issueKey),
bodyKey = "notification.incident.body",
bodyArgs = arrayOf(severity.name, issueKey),
)
}
}
return incident
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,11 @@ class SlaMonitorJob(
notificationService.createDirect(
userId = uid,
type = NotificationType.SLA_BREACHED,
title = "SLA Breached: ${issue.key}",
body = "Issue ${issue.key} has exceeded its SLA resolution time of ${policy.resolutionMinutes} minutes.",
link = "/issues/${issue.key}"
titleKey = "notification.slaBreached.title",
link = "/issues/${issue.key}",
titleArgs = arrayOf(issue.key),
bodyKey = "notification.slaBreached.body",
bodyArgs = arrayOf(issue.key, policy.resolutionMinutes.toString()),
)
}
}
Expand Down
14 changes: 14 additions & 0 deletions backend/src/main/resources/messages.properties
Original file line number Diff line number Diff line change
Expand Up @@ -140,3 +140,17 @@ serviceDesk.notEnabled=Service desk not enabled for project: {0}
serviceDesk.invalidPriority=Invalid priority: {0}. Valid values: {1}
# --- incident ---
incident.invalidSeverity=Invalid severity ''{0}''. Must be one of: {1}

# --- email ---
email.mention.subject=You were mentioned in {0}
email.mention.body={0}: {1}\n\n{2}
email.assigned.subject=You were assigned to {0}
email.assigned.body=You have been assigned to: {0}\n{1}
# --- notification titles + templated bodies ---
notification.mention.title=You were mentioned in {0}
notification.assigned.title=You were assigned to {0}
notification.incident.title=Incident declared: {0} on issue {1}
notification.incident.body=A {0} incident has been declared for issue {1}.
notification.slaBreached.title=SLA Breached: {0}
notification.slaBreached.body=Issue {0} has exceeded its SLA resolution time of {1} minutes.
notification.automation.title=Automation: {0}
14 changes: 14 additions & 0 deletions backend/src/main/resources/messages_de.properties
Original file line number Diff line number Diff line change
Expand Up @@ -140,3 +140,17 @@ serviceDesk.notEnabled=Servicedesk ist f\u00fcr Projekt {0} nicht aktiviert
serviceDesk.invalidPriority=Ung\u00fcltige Priorit\u00e4t: {0}. G\u00fcltige Werte: {1}
# --- incident ---
incident.invalidSeverity=Ung\u00fcltiger Schweregrad ''{0}''. Muss einer von: {1}

# --- email ---
email.mention.subject=Sie wurden in {0} erw\u00e4hnt
email.mention.body={0}: {1}\n\n{2}
email.assigned.subject=Ihnen wurde {0} zugewiesen
email.assigned.body=Sie wurden zugewiesen zu: {0}\n{1}
# --- notification titles + templated bodies ---
notification.mention.title=Sie wurden in {0} erw\u00e4hnt
notification.assigned.title=Ihnen wurde {0} zugewiesen
notification.incident.title=Incident gemeldet: {0} f\u00fcr Vorgang {1}
notification.incident.body=Ein {0}-Incident wurde f\u00fcr Vorgang {1} gemeldet.
notification.slaBreached.title=SLA verletzt: {0}
notification.slaBreached.body=Vorgang {0} hat seine SLA-L\u00f6sungszeit von {1} Minuten \u00fcberschritten.
notification.automation.title=Automatisierung: {0}
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,17 @@ class ActionExecutorTest {
fun `SEND_NOTIFICATION calls notificationService`() {
val issue = makeIssue()
executor.execute(listOf(makeAction(ActionType.SEND_NOTIFICATION, """{"message":"Hello"}""")), issue)
verify { notificationService.createDirect(any(), NotificationType.AUTOMATION, any(), any(), any()) }
verify {
notificationService.createDirect(
userId = any(),
type = NotificationType.AUTOMATION,
titleKey = "notification.automation.title",
link = any(),
titleArgs = any(),
bodyKey = any(),
bodyArgs = any(),
rawBody = "Hello",
)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package com.taskowolf.i18n

import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.springframework.context.support.ResourceBundleMessageSource
import java.util.Locale

class EmailNotificationMessagesTest {
private val src = ResourceBundleMessageSource().apply {
setBasename("messages"); setDefaultEncoding("UTF-8"); setFallbackToSystemLocale(false)
}
private fun en(key: String, vararg a: Any?) = src.getMessage(key, a, Locale.ENGLISH)
private fun de(key: String, vararg a: Any?) = src.getMessage(key, a, Locale.GERMAN)

@Test
fun `email keys render en and de`() {
assertEquals("You were mentioned in WOLF-1", en("email.mention.subject", "WOLF-1"))
assertEquals("Sie wurden in WOLF-1 erwähnt", de("email.mention.subject", "WOLF-1"))
assertEquals("WOLF-1: My Issue\n\nGreat comment",
en("email.mention.body", "WOLF-1", "My Issue", "Great comment"))
assertEquals("WOLF-1: My Issue\n\nGreat comment",
de("email.mention.body", "WOLF-1", "My Issue", "Great comment"))
assertEquals("You were assigned to WOLF-1", en("email.assigned.subject", "WOLF-1"))
assertEquals("Ihnen wurde WOLF-1 zugewiesen", de("email.assigned.subject", "WOLF-1"))
assertEquals("You have been assigned to: WOLF-1\nMy Issue",
en("email.assigned.body", "WOLF-1", "My Issue"))
assertEquals("Sie wurden zugewiesen zu: WOLF-1\nMy Issue",
de("email.assigned.body", "WOLF-1", "My Issue"))
}

@Test
fun `notification title keys render en and de`() {
assertEquals("You were mentioned in WOLF-1", en("notification.mention.title", "WOLF-1"))
assertEquals("Sie wurden in WOLF-1 erwähnt", de("notification.mention.title", "WOLF-1"))
assertEquals("You were assigned to WOLF-1", en("notification.assigned.title", "WOLF-1"))
assertEquals("Ihnen wurde WOLF-1 zugewiesen", de("notification.assigned.title", "WOLF-1"))
assertEquals("Automation: WOLF-1", en("notification.automation.title", "WOLF-1"))
assertEquals("Automatisierung: WOLF-1", de("notification.automation.title", "WOLF-1"))
}

@Test
fun `incident and sla keys render en and de (issue key + numeric-as-string)`() {
assertEquals("Incident declared: P1 on issue WOLF-1", en("notification.incident.title", "P1", "WOLF-1"))
assertEquals("Incident gemeldet: P1 für Vorgang WOLF-1", de("notification.incident.title", "P1", "WOLF-1"))
assertEquals("A P1 incident has been declared for issue WOLF-1.",
en("notification.incident.body", "P1", "WOLF-1"))
assertEquals("Ein P1-Incident wurde für Vorgang WOLF-1 gemeldet.",
de("notification.incident.body", "P1", "WOLF-1"))
assertEquals("SLA Breached: WOLF-1", en("notification.slaBreached.title", "WOLF-1"))
assertEquals("SLA verletzt: WOLF-1", de("notification.slaBreached.title", "WOLF-1"))
assertEquals("Issue WOLF-1 has exceeded its SLA resolution time of 1440 minutes.",
en("notification.slaBreached.body", "WOLF-1", "1440"))
assertEquals("Vorgang WOLF-1 hat seine SLA-Lösungszeit von 1440 Minuten überschritten.",
de("notification.slaBreached.body", "WOLF-1", "1440"))
}
}
Loading
Loading