feat(#2): Get notifications working again

This commit is contained in:
2025-11-01 08:47:02 +01:00
parent 841341857d
commit 2cf492cd6c
16 changed files with 609 additions and 380 deletions

View File

@@ -609,6 +609,12 @@ paths:
tags:
- notification
parameters:
- in: query
name: organizationId
required: true
schema:
type: string
description: Organization ID to get notifications for
- in: query
name: page
schema:
@@ -663,6 +669,13 @@ paths:
operationId: getUnreadNotifications
tags:
- notification
parameters:
- in: query
name: organizationId
required: true
schema:
type: string
description: Organization ID to get unread notifications for
responses:
"200":
description: List of unread notifications
@@ -674,15 +687,30 @@ paths:
$ref: "#/components/schemas/NotificationDto"
"401":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/Unauthorized"
"403":
description: User is not authorized to access this organization
"500":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServerError"
/notifications/unread/count:
get:
summary: Get count of unread notifications for the current user
summary: Get count of unread notifications for a user (public endpoint)
operationId: getUnreadNotificationCount
tags:
- notification
parameters:
- in: query
name: userId
required: true
schema:
type: string
description: Keycloak user ID to get notification count for
- in: query
name: organizationId
required: true
schema:
type: string
description: Organization ID to get notification count for
responses:
"200":
description: Count of unread notifications
@@ -691,8 +719,6 @@ paths:
schema:
type: integer
format: int64
"401":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/Unauthorized"
"500":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServerError"
@@ -702,11 +728,43 @@ paths:
operationId: markAllNotificationsAsRead
tags:
- notification
parameters:
- in: query
name: organizationId
required: true
schema:
type: string
description: Organization ID to mark notifications as read for
responses:
"204":
description: All notifications marked as read
"401":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/Unauthorized"
"403":
description: User is not authorized to access this organization
"500":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServerError"
/notifications/clear-all:
delete:
summary: Clear all notifications for the current user
operationId: clearAllNotifications
tags:
- notification
parameters:
- in: query
name: organizationId
required: true
schema:
type: string
description: Organization ID to clear notifications for
responses:
"204":
description: All notifications cleared
"401":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/Unauthorized"
"403":
description: User is not authorized to access this organization
"500":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServerError"
@@ -723,6 +781,13 @@ paths:
operationId: markNotificationAsRead
tags:
- notification
parameters:
- in: query
name: organizationId
required: true
schema:
type: string
description: Organization ID for authorization
responses:
"200":
description: Notification marked as read
@@ -734,6 +799,8 @@ paths:
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/NotFound"
"401":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/Unauthorized"
"403":
description: User is not authorized to access this organization
"500":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServerError"
@@ -1130,7 +1197,6 @@ components:
- message
- clickTarget
- isRead
- role
- type
- createdAt
- organizationId
@@ -1150,8 +1216,10 @@ components:
nullable: true
allOf:
- $ref: "#/components/schemas/UserDto"
role:
targetRoles:
type: string
description: Comma-separated list of target roles (only for role-based notifications)
nullable: true
type:
$ref: "#/components/schemas/NotificationType"
organizationId:
@@ -1166,7 +1234,6 @@ components:
- title
- message
- clickTarget
- role
- type
- organizationId
properties:
@@ -1176,16 +1243,21 @@ components:
type: string
clickTarget:
type: string
recipient:
nullable: true
allOf:
- $ref: "#/components/schemas/UserDto"
role:
recipientId:
type: string
description: Keycloak ID of the recipient user. If not provided, notification will be role-based or organization-wide.
nullable: true
targetRoles:
type: array
items:
type: string
description: List of roles to send notification to. If both recipientId and targetRoles are null, notification will be sent to all organization members.
nullable: true
type:
$ref: "#/components/schemas/NotificationType"
organizationId:
type: string
description: The organization ID for this notification
PagedNotificationDto:
type: object

View File

@@ -5,20 +5,22 @@ import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotCreatedExc
import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotDeletedException
import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotFoundException
import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotUpdatedException
import com.betriebsratkanzlei.legalconsenthub.notification.NotificationService
import com.betriebsratkanzlei.legalconsenthub_api.model.ApplicationFormDto
import com.betriebsratkanzlei.legalconsenthub_api.model.ApplicationFormStatus
import com.betriebsratkanzlei.legalconsenthub_api.model.CreateApplicationFormDto
import com.betriebsratkanzlei.legalconsenthub_api.model.CreateNotificationDto
import com.betriebsratkanzlei.legalconsenthub_api.model.NotificationType
import org.springframework.data.domain.Page
import org.springframework.data.domain.PageRequest
import org.springframework.stereotype.Service
import java.util.UUID
// import com.betriebsratkanzlei.legalconsenthub.notification.NotificationService
@Service
class ApplicationFormService(
private val applicationFormRepository: ApplicationFormRepository,
private val applicationFormMapper: ApplicationFormMapper,
// private val notificationService: NotificationService
private val notificationService: NotificationService,
) {
fun createApplicationForm(createApplicationFormDto: CreateApplicationFormDto): ApplicationForm {
val applicationForm = applicationFormMapper.toApplicationForm(createApplicationFormDto)
@@ -85,32 +87,29 @@ class ApplicationFormService(
throw ApplicationFormNotUpdatedException(e, id)
}
// Create notifications for relevant users
createSubmissionNotifications(savedApplicationForm)
createNotificationForOrganization(savedApplicationForm)
return savedApplicationForm
}
private fun createSubmissionNotifications(applicationForm: ApplicationForm) {
private fun createNotificationForOrganization(applicationForm: ApplicationForm) {
val title = "Neuer Mitbestimmungsantrag eingereicht"
val message =
"Ein neuer Mitbestimmungsantrag '${applicationForm.name}' wurde von " +
"${applicationForm.createdBy.name} eingereicht und wartet auf Ihre Bearbeitung."
"${applicationForm.createdBy.name} eingereicht."
val clickTarget = "/application-forms/${applicationForm.id}/0"
// // Create separate notification for each role that should be notified
// val rolesToNotify = listOf("admin", "works_council_member", "employer", "employee")
//
// rolesToNotify.forEach { role ->
// notificationService.createNotification(
// title = title,
// message = message,
// clickTarget = clickTarget,
// recipient = null,
// role = role,
// organizationId = applicationForm.organizationId,
// type = NotificationType.INFO
// )
// }
val createNotificationDto =
CreateNotificationDto(
title = title,
message = message,
clickTarget = clickTarget,
recipientId = null,
targetRoles = null,
type = NotificationType.INFO,
organizationId = applicationForm.organizationId,
)
notificationService.createNotificationForOrganization(createNotificationDto)
}
}

View File

@@ -3,6 +3,7 @@ package com.betriebsratkanzlei.legalconsenthub.config
import com.betriebsratkanzlei.legalconsenthub.security.CustomJwtAuthenticationConverter
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.core.annotation.Order
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
@@ -14,16 +15,32 @@ import org.springframework.security.web.SecurityFilterChain
@EnableMethodSecurity
class SecurityConfig {
@Bean
fun configure(
@Order(1)
fun publicFilterChain(http: HttpSecurity): SecurityFilterChain {
http {
securityMatcher(
"/swagger-ui/**",
"/v3/**",
"/actuator/**",
"/notifications/unread/count",
)
csrf { disable() }
authorizeHttpRequests {
authorize(anyRequest, permitAll)
}
}
return http.build()
}
@Bean
@Order(2)
fun protectedFilterChain(
http: HttpSecurity,
customJwtAuthenticationConverter: CustomJwtAuthenticationConverter,
): SecurityFilterChain {
http {
csrf { disable() }
authorizeHttpRequests {
authorize("/swagger-ui/**", permitAll)
authorize("/v3/**", permitAll)
authorize("/actuator/**", permitAll)
authorize(anyRequest, authenticated)
}
oauth2ResourceServer {

View File

@@ -32,9 +32,9 @@ class Notification(
var isRead: Boolean = false,
@ManyToOne
@JoinColumn(name = "recipient_id", nullable = true)
var recipient: User?,
@Column(nullable = false)
var role: String = "",
var recipient: User? = null,
@Column(nullable = true, columnDefinition = "TEXT")
var targetRoles: String? = null,
@Enumerated(EnumType.STRING)
@Column(nullable = false)
var type: NotificationType = NotificationType.INFO,

View File

@@ -1,83 +1,152 @@
// package com.betriebsratkanzlei.legalconsenthub.notification
//
// import com.betriebsratkanzlei.legalconsenthub.security.CustomJwtTokenPrincipal
// import com.betriebsratkanzlei.legalconsenthub_api.api.NotificationApi
// import com.betriebsratkanzlei.legalconsenthub_api.model.CreateNotificationDto
// import com.betriebsratkanzlei.legalconsenthub_api.model.NotificationDto
// import com.betriebsratkanzlei.legalconsenthub_api.model.PagedNotificationDto
// import org.springframework.http.ResponseEntity
// import org.springframework.security.core.context.SecurityContextHolder
// import org.springframework.web.bind.annotation.RestController
// import java.util.UUID
//
// @RestController
// class NotificationController(
// private val notificationService: NotificationService,
// private val notificationMapper: NotificationMapper,
// private val pagedNotificationMapper: PagedNotificationMapper
// ) : NotificationApi {
//
// override fun getNotifications(page: Int, size: Int): ResponseEntity<PagedNotificationDto> {
// val principal = SecurityContextHolder.getContext().authentication.principal as CustomJwtTokenPrincipal
// val recipientId = principal.id ?: throw IllegalStateException("User ID not found")
// val organizationId = principal.organizationId ?: throw IllegalStateException("Organization ID not found")
//
// val notifications = notificationService.getNotifications(
// recipientId = recipientId,
// organizationId = organizationId,
// page = page,
// size = size
// )
//
// return ResponseEntity.ok(pagedNotificationMapper.toPagedNotificationDto(notifications))
// }
//
// override fun getUnreadNotifications(): ResponseEntity<List<NotificationDto>> {
// val principal = SecurityContextHolder.getContext().authentication.principal as CustomJwtTokenPrincipal
// val recipientId = principal.id ?: throw IllegalStateException("User ID not found")
// val organizationId = principal.organizationId ?: throw IllegalStateException("Organization ID not found")
//
// val notifications = notificationService.getUnreadNotifications(
// recipientId = recipientId,
// organizationId = organizationId
// )
//
// return ResponseEntity.ok(notifications.map { notificationMapper.toNotificationDto(it) })
// }
//
// override fun getUnreadNotificationCount(): ResponseEntity<kotlin.Long> {
// val principal = SecurityContextHolder.getContext().authentication.principal as CustomJwtTokenPrincipal
// val recipientId = principal.id ?: throw IllegalStateException("User ID not found")
// val organizationId = principal.organizationId ?: throw IllegalStateException("Organization ID not found")
//
// val count = notificationService.getUnreadNotificationCount(
// recipientId = recipientId,
// organizationId = organizationId
// )
//
// return ResponseEntity.ok(count)
// }
//
// override fun markAllNotificationsAsRead(): ResponseEntity<Unit> {
// val principal = SecurityContextHolder.getContext().authentication.principal as CustomJwtTokenPrincipal
// val recipientId = principal.id ?: throw IllegalStateException("User ID not found")
// val organizationId = principal.organizationId ?: throw IllegalStateException("Organization ID not found")
//
// notificationService.markAllAsRead(
// recipientId = recipientId,
// organizationId = organizationId
// )
//
// return ResponseEntity.noContent().build()
// }
//
// override fun markNotificationAsRead(id: UUID): ResponseEntity<NotificationDto> {
// val notification = notificationService.markNotificationAsRead(id)
// return ResponseEntity.ok(notificationMapper.toNotificationDto(notification))
// }
//
// override fun createNotification(createNotificationDto: CreateNotificationDto): ResponseEntity<NotificationDto> {
// val notification = notificationService.createNotification(createNotificationDto)
// return ResponseEntity.ok(notificationMapper.toNotificationDto(notification))
// }
// }
package com.betriebsratkanzlei.legalconsenthub.notification
import com.betriebsratkanzlei.legalconsenthub.security.CustomJwtTokenPrincipal
import com.betriebsratkanzlei.legalconsenthub_api.api.NotificationApi
import com.betriebsratkanzlei.legalconsenthub_api.model.CreateNotificationDto
import com.betriebsratkanzlei.legalconsenthub_api.model.NotificationDto
import com.betriebsratkanzlei.legalconsenthub_api.model.PagedNotificationDto
import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.web.bind.annotation.RestController
import java.util.UUID
@RestController
class NotificationController(
private val notificationService: NotificationService,
private val notificationMapper: NotificationMapper,
private val pagedNotificationMapper: PagedNotificationMapper,
) : NotificationApi {
@PreAuthorize(
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL', 'EMPLOYEE')",
)
override fun getNotifications(
organizationId: String,
page: Int,
size: Int,
): ResponseEntity<PagedNotificationDto> {
val principal = SecurityContextHolder.getContext().authentication.principal as CustomJwtTokenPrincipal
val recipientId = principal.id ?: throw IllegalStateException("User ID not found")
validateOrganizationAccess(principal, organizationId)
val userRoles = principal.roles
val notifications =
notificationService.getNotifications(
recipientKeycloakId = recipientId,
organizationId = organizationId,
userRoles = userRoles,
page = page,
size = size,
)
return ResponseEntity.ok(pagedNotificationMapper.toPagedNotificationDto(notifications))
}
@PreAuthorize(
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL', 'EMPLOYEE')",
)
override fun getUnreadNotifications(organizationId: String): ResponseEntity<List<NotificationDto>> {
val principal = SecurityContextHolder.getContext().authentication.principal as CustomJwtTokenPrincipal
val recipientId = principal.id ?: throw IllegalStateException("User ID not found")
validateOrganizationAccess(principal, organizationId)
val userRoles = principal.roles
val notifications =
notificationService.getUnreadNotifications(
recipientKeycloakId = recipientId,
organizationId = organizationId,
userRoles = userRoles,
)
return ResponseEntity.ok(notifications.map { notificationMapper.toNotificationDto(it) })
}
override fun getUnreadNotificationCount(
userId: String,
organizationId: String,
): ResponseEntity<kotlin.Long> {
val count =
notificationService.getUnreadNotificationCount(
recipientKeycloakId = userId,
organizationId = organizationId,
userRoles = emptyList(),
)
return ResponseEntity.ok(count)
}
@PreAuthorize(
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL', 'EMPLOYEE')",
)
override fun markAllNotificationsAsRead(organizationId: String): ResponseEntity<Unit> {
val principal = SecurityContextHolder.getContext().authentication.principal as CustomJwtTokenPrincipal
val recipientId = principal.id ?: throw IllegalStateException("User ID not found")
validateOrganizationAccess(principal, organizationId)
notificationService.markAllAsRead(
recipientKeycloakId = recipientId,
organizationId = organizationId,
)
return ResponseEntity.noContent().build()
}
@PreAuthorize(
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL', 'EMPLOYEE')",
)
override fun markNotificationAsRead(
id: UUID,
organizationId: String,
): ResponseEntity<NotificationDto> {
val principal = SecurityContextHolder.getContext().authentication.principal as CustomJwtTokenPrincipal
validateOrganizationAccess(principal, organizationId)
val notification = notificationService.markNotificationAsRead(id)
return ResponseEntity.ok(notificationMapper.toNotificationDto(notification))
}
@PreAuthorize(
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL')",
)
override fun createNotification(createNotificationDto: CreateNotificationDto): ResponseEntity<NotificationDto> {
val principal = SecurityContextHolder.getContext().authentication.principal as CustomJwtTokenPrincipal
validateOrganizationAccess(principal, createNotificationDto.organizationId)
val notification =
when {
createNotificationDto.recipientId != null ->
notificationService.createNotificationForUser(createNotificationDto)
!createNotificationDto.targetRoles.isNullOrEmpty() ->
notificationService.createNotificationForRoles(createNotificationDto)
else ->
notificationService.createNotificationForOrganization(createNotificationDto)
}
return ResponseEntity.ok(notificationMapper.toNotificationDto(notification))
}
@PreAuthorize(
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL', 'EMPLOYEE')",
)
override fun clearAllNotifications(organizationId: String): ResponseEntity<Unit> {
val principal = SecurityContextHolder.getContext().authentication.principal as CustomJwtTokenPrincipal
val recipientId = principal.id ?: throw IllegalStateException("User ID not found")
validateOrganizationAccess(principal, organizationId)
notificationService.clearAllNotifications(
recipientKeycloakId = recipientId,
organizationId = organizationId,
)
return ResponseEntity.noContent().build()
}
private fun validateOrganizationAccess(
principal: CustomJwtTokenPrincipal,
organizationId: String,
) {
if (organizationId !in principal.organizationIds) {
throw SecurityException("User is not authorized to access organization: $organizationId")
}
}
}

View File

@@ -1,5 +1,6 @@
package com.betriebsratkanzlei.legalconsenthub.notification
import com.betriebsratkanzlei.legalconsenthub.user.User
import com.betriebsratkanzlei.legalconsenthub.user.UserMapper
import com.betriebsratkanzlei.legalconsenthub_api.model.CreateNotificationDto
import com.betriebsratkanzlei.legalconsenthub_api.model.NotificationDto
@@ -17,21 +18,29 @@ class NotificationMapper(
clickTarget = notification.clickTarget,
isRead = notification.isRead,
recipient = notification.recipient?.let { userMapper.toUserDto(it) },
role = notification.role,
type = notification.type,
organizationId = notification.organizationId,
createdAt = notification.createdAt!!,
targetRoles = notification.targetRoles,
)
fun toNotification(createNotificationDto: CreateNotificationDto): Notification =
Notification(
fun toNotification(
createNotificationDto: CreateNotificationDto,
recipient: User? = null,
targetRoles: String? = null,
): Notification {
if (recipient != null && targetRoles != null) {
throw IllegalArgumentException("Only one of recipient or targetRoles can be provided")
}
return Notification(
title = createNotificationDto.title,
message = createNotificationDto.message,
clickTarget = createNotificationDto.clickTarget,
isRead = false,
recipient = createNotificationDto.recipient?.let { userMapper.toUser(it) },
role = createNotificationDto.role,
recipient = recipient,
targetRoles = targetRoles,
type = createNotificationDto.type,
organizationId = createNotificationDto.organizationId,
)
}
}

View File

@@ -1,84 +1,80 @@
// package com.betriebsratkanzlei.legalconsenthub.notification
//
// 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.Modifying
// import org.springframework.data.jpa.repository.Query
// import org.springframework.data.repository.query.Param
// import org.springframework.stereotype.Repository
// import java.util.UUID
//
// @Repository
// interface NotificationRepository : JpaRepository<Notification, UUID> {
//
// fun findByRecipientIdOrderByCreatedAtDesc(recipientId: String?, pageable: Pageable): Page<Notification>
// fun findByRecipientIdAndIsReadFalseOrderByCreatedAtDesc(recipientId: String?): List<Notification>
// fun countByRecipientIdAndIsReadFalse(recipientId: String?): Long
//
// @Query(
// """
// SELECT n FROM Notification n WHERE
// (n.recipient.keycloakId = :recipientId) OR
// (n.recipient IS NULL AND CONCAT(n.organizationId, ':', n.role) IN :orgRolePairs) OR
// (n.recipient IS NULL AND n.organizationId IN :organizationIds AND (n.role IS NULL OR n.role = ''))
// ORDER BY n.createdAt DESC
// """
// )
// fun findUserNotificationsByOrgRole(
// @Param("recipientId") recipientId: String,
// @Param("organizationIds") organizationIds: List<String>,
// @Param("orgRolePairs") orgRolePairs: List<String>,
// pageable: Pageable
// ): Page<Notification>
//
// @Query(
// """
// SELECT n FROM Notification n WHERE
// ((n.recipient.keycloakId = :recipientId) OR
// (n.recipient IS NULL AND CONCAT(n.organizationId, ':', n.role) IN :orgRolePairs) OR
// (n.recipient IS NULL AND n.organizationId IN :organizationIds AND (n.role IS NULL OR n.role = '')))
// AND n.isRead = false
// ORDER BY n.createdAt DESC
// """
// )
// fun findUnreadUserNotificationsByOrgRole(
// @Param("recipientId") recipientId: String,
// @Param("organizationIds") organizationIds: List<String>,
// @Param("orgRolePairs") orgRolePairs: List<String>
// ): List<Notification>
//
// @Query(
// """
// SELECT COUNT(n) FROM Notification n WHERE
// ((n.recipient.keycloakId = :recipientId) OR
// (n.recipient IS NULL AND CONCAT(n.organizationId, ':', n.role) IN :orgRolePairs) OR
// (n.recipient IS NULL AND n.organizationId IN :organizationIds AND (n.role IS NULL OR n.role = '')))
// AND n.isRead = false
// """
// )
// fun countUnreadUserNotifications(
// @Param("recipientId") recipientId: String,
// @Param("organizationIds") organizationIds: List<String>,
// @Param("orgRolePairs") orgRolePairs: List<String>
// ): Long
//
// @Modifying
// @Query(
// """
// UPDATE Notification n SET n.isRead = true WHERE
// (n.recipient.keycloakId = :recipientId) OR
// (n.recipient IS NULL AND CONCAT(n.organizationId, ':', n.role) IN :orgRolePairs) OR
// (n.recipient IS NULL AND n.organizationId IN :organizationIds AND (n.role IS NULL OR n.role = ''))
// """
// )
// fun markAllUserNotificationsAsRead(
// @Param("recipientId") recipientId: String,
// @Param("organizationIds") organizationIds: List<String>,
// @Param("orgRolePairs") orgRolePairs: List<String>
// )
//
// @Modifying
// @Query("UPDATE Notification n SET n.isRead = true WHERE n.recipient.keycloakId = :recipientId")
// fun markAllAsReadByRecipientId(@Param("recipientId") recipientId: String)
// }
package com.betriebsratkanzlei.legalconsenthub.notification
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.Modifying
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import org.springframework.stereotype.Repository
import java.util.UUID
@Repository
interface NotificationRepository : JpaRepository<Notification, UUID> {
@Query(
"""
SELECT n FROM Notification n
WHERE n.organizationId = :organizationId
AND (n.recipient.keycloakId = :keycloakId OR n.recipient IS NULL)
ORDER BY n.createdAt DESC
""",
)
fun findByRecipientOrWithoutRecipientAndOrganizationId(
@Param("keycloakId") keycloakId: String,
@Param("organizationId") organizationId: String,
pageable: Pageable,
): Page<Notification>
@Query(
"""
SELECT n FROM Notification n
WHERE n.organizationId = :organizationId
AND (n.recipient.keycloakId = :keycloakId OR n.recipient IS NULL)
AND n.isRead = false
ORDER BY n.createdAt DESC
""",
)
fun findUnreadByRecipientOrWithoutRecipientAndOrganizationId(
@Param("keycloakId") keycloakId: String,
@Param("organizationId") organizationId: String,
): List<Notification>
@Query(
"""
SELECT COUNT(n) FROM Notification n
WHERE n.organizationId = :organizationId
AND (n.recipient.keycloakId = :keycloakId OR n.recipient IS NULL)
AND n.isRead = false
""",
)
fun countUnreadByRecipientOrWithoutRecipientAndOrganizationId(
@Param("keycloakId") keycloakId: String,
@Param("organizationId") organizationId: String,
): Long
@Modifying
@Query(
"""
UPDATE Notification n SET n.isRead = true
WHERE n.organizationId = :organizationId
AND (n.recipient.keycloakId = :keycloakId OR n.recipient IS NULL)
""",
)
fun markAllAsReadByRecipientAndOrganization(
@Param("keycloakId") keycloakId: String,
@Param("organizationId") organizationId: String,
)
@Modifying
@Query(
"""
DELETE FROM Notification n
WHERE n.organizationId = :organizationId
AND (n.recipient.keycloakId = :keycloakId OR n.recipient IS NULL)
""",
)
fun deleteAllByRecipientAndOrganization(
@Param("keycloakId") keycloakId: String,
@Param("organizationId") organizationId: String,
)
}

View File

@@ -1,127 +1,163 @@
// package com.betriebsratkanzlei.legalconsenthub.notification
//
// import com.betriebsratkanzlei.legalconsenthub.user.User
// import com.betriebsratkanzlei.legalconsenthub.user.UserRepository
// import com.betriebsratkanzlei.legalconsenthub.user.UserRoleConverter
// import com.betriebsratkanzlei.legalconsenthub_api.model.CreateNotificationDto
// import com.betriebsratkanzlei.legalconsenthub_api.model.NotificationType
// 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.UUID
//
// @Service
// class NotificationService(
// private val notificationRepository: NotificationRepository,
// private val notificationMapper: NotificationMapper,
// private val userRepository: UserRepository,
// private val userRoleConverter: UserRoleConverter
// ) {
//
// fun createNotification(createNotificationDto: CreateNotificationDto): Notification {
// val notification = notificationMapper.toNotification(createNotificationDto)
// return notificationRepository.save(notification)
// }
//
// fun createNotification(
// title: String,
// message: String,
// clickTarget: String,
// recipient: User?,
// role: String,
// organizationId: String,
// type: NotificationType = NotificationType.INFO
// ): Notification {
// val notification = Notification(
// title = title,
// message = message,
// clickTarget = clickTarget,
// recipient = recipient,
// role = role,
// type = type,
// organizationId = organizationId
// )
// return notificationRepository.save(notification)
// }
//
// fun getNotifications(
// recipientId: String,
// organizationId: String,
// page: Int = 0,
// size: Int = 20
// ): Page<Notification> {
// val user = userRepository.findById(recipientId)
// .orElseThrow { IllegalArgumentException("User not found with id: $recipientId") }
//
// val userRoles = userRoleConverter.getRolesForOrganization(user.organizationRoles, organizationId)
// val orgRolePairs = userRoles.map { role -> "$organizationId:${role.value}" }
//
// val pageable = PageRequest.of(page, size)
// return if (userRoles.isNotEmpty()) {
// notificationRepository.findUserNotificationsByOrgRole(recipientId, listOf(organizationId), orgRolePairs, pageable)
// } else {
// notificationRepository.findByRecipientIdOrderByCreatedAtDesc(recipientId, pageable)
// }
// }
//
// fun getUnreadNotifications(
// recipientId: String,
// organizationId: String
// ): List<Notification> {
// val user = userRepository.findById(recipientId)
// .orElseThrow { IllegalArgumentException("User not found with id: $recipientId") }
//
// val userRoles = userRoleConverter.getRolesForOrganization(user.organizationRoles, organizationId)
// val orgRolePairs = userRoles.map { role -> "$organizationId:${role.value}" }
//
// return if (userRoles.isNotEmpty()) {
// notificationRepository.findUnreadUserNotificationsByOrgRole(recipientId, listOf(organizationId), orgRolePairs)
// } else {
// notificationRepository.findByRecipientIdAndIsReadFalseOrderByCreatedAtDesc(recipientId)
// }
// }
//
// fun getUnreadNotificationCount(
// recipientId: String,
// organizationId: String
// ): Long {
// val user = userRepository.findById(recipientId)
// .orElseThrow { IllegalArgumentException("User not found with id: $recipientId") }
//
// val userRoles = userRoleConverter.getRolesForOrganization(user.organizationRoles, organizationId)
// val orgRolePairs = userRoles.map { role -> "$organizationId:${role.value}" }
//
// return if (userRoles.isNotEmpty()) {
// notificationRepository.countUnreadUserNotifications(recipientId, listOf(organizationId), orgRolePairs)
// } else {
// notificationRepository.countByRecipientIdAndIsReadFalse(recipientId)
// }
// }
//
// @Transactional
// fun markAllAsRead(
// recipientId: String,
// organizationId: String
// ) {
// val user = userRepository.findById(recipientId)
// .orElseThrow { IllegalArgumentException("User not found with id: $recipientId") }
//
// val userRoles = userRoleConverter.getRolesForOrganization(user.organizationRoles, organizationId)
// val orgRolePairs = userRoles.map { role -> "$organizationId:${role.value}" }
//
// if (userRoles.isNotEmpty()) {
// notificationRepository.markAllUserNotificationsAsRead(recipientId, listOf(organizationId), orgRolePairs)
// } else {
// notificationRepository.markAllAsReadByRecipientId(recipientId)
// }
// }
//
// @Transactional
// fun markNotificationAsRead(notificationId: UUID): Notification {
// val notification = notificationRepository.findById(notificationId)
// .orElseThrow { IllegalArgumentException("Notification not found with id: $notificationId") }
// notification.isRead = true
// return notificationRepository.save(notification)
// }
// }
package com.betriebsratkanzlei.legalconsenthub.notification
import com.betriebsratkanzlei.legalconsenthub.security.CustomJwtTokenPrincipal
import com.betriebsratkanzlei.legalconsenthub.user.UserRepository
import com.betriebsratkanzlei.legalconsenthub_api.model.CreateNotificationDto
import org.springframework.data.domain.Page
import org.springframework.data.domain.PageImpl
import org.springframework.data.domain.PageRequest
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.util.UUID
@Service
class NotificationService(
private val notificationRepository: NotificationRepository,
private val userRepository: UserRepository,
private val notificationMapper: NotificationMapper,
) {
fun createNotificationForUser(createNotificationDto: CreateNotificationDto): Notification {
val recipientKeycloakId =
createNotificationDto.recipientId
?: throw IllegalArgumentException("recipientId must be provided for user notifications")
val recipient =
userRepository
.findById(recipientKeycloakId)
.orElseThrow { IllegalArgumentException("User not found with id: $recipientKeycloakId") }
val notification =
notificationMapper.toNotification(
createNotificationDto = createNotificationDto,
recipient = recipient,
)
return notificationRepository.save(notification)
}
@Transactional
fun createNotificationForRoles(createNotificationDto: CreateNotificationDto): Notification {
val targetRoles =
createNotificationDto.targetRoles
?: throw IllegalArgumentException("targetRoles must be provided for role-based notifications")
val notification =
notificationMapper.toNotification(
createNotificationDto = createNotificationDto,
targetRoles = targetRoles.joinToString(","),
)
return notificationRepository.save(notification)
}
@Transactional
fun createNotificationForOrganization(createNotificationDto: CreateNotificationDto): Notification {
val notification =
notificationMapper.toNotification(
createNotificationDto = createNotificationDto,
)
return notificationRepository.save(notification)
}
fun getNotifications(
recipientKeycloakId: String,
organizationId: String,
userRoles: List<String>,
page: Int = 0,
size: Int = 20,
): Page<Notification> {
val pageable = PageRequest.of(page, size)
val allNotifications =
notificationRepository.findByRecipientOrWithoutRecipientAndOrganizationId(
recipientKeycloakId,
organizationId,
pageable,
)
val filteredContent =
allNotifications.content.filter { notification ->
when {
// Direct recipient notification
notification.recipient != null -> true
// Organization-wide notification (no recipient, no roles)
notification.targetRoles.isNullOrEmpty() -> true
// Role-based notification
else -> {
val targetRolesList = notification.targetRoles!!.split(",")
userRoles.any { it in targetRolesList }
}
}
}
return PageImpl(filteredContent, pageable, allNotifications.totalElements)
}
fun getUnreadNotifications(
recipientKeycloakId: String,
organizationId: String,
userRoles: List<String>,
): List<Notification> {
val allNotifications =
notificationRepository.findUnreadByRecipientOrWithoutRecipientAndOrganizationId(
recipientKeycloakId,
organizationId,
)
return allNotifications.filter { notification ->
when {
// Direct recipient notification
notification.recipient != null -> true
// Organization-wide notification (no recipient, no roles)
notification.targetRoles.isNullOrEmpty() -> true
// Role-based notification
else -> {
val targetRolesList = notification.targetRoles!!.split(",")
userRoles.any { it in targetRolesList }
}
}
}
}
fun getUnreadNotificationCount(
recipientKeycloakId: String,
organizationId: String,
userRoles: List<String>,
): Long = getUnreadNotifications(recipientKeycloakId, organizationId, userRoles).size.toLong()
@Transactional
fun markAllAsRead(
recipientKeycloakId: String,
organizationId: String,
) {
notificationRepository.markAllAsReadByRecipientAndOrganization(recipientKeycloakId, organizationId)
}
@Transactional
fun markNotificationAsRead(notificationId: UUID): Notification {
val notification =
notificationRepository
.findById(notificationId)
.orElseThrow { IllegalArgumentException("Notification not found with id: $notificationId") }
val principal = SecurityContextHolder.getContext().authentication.principal as? CustomJwtTokenPrincipal
val currentUserKeycloakId =
principal?.id
?: throw IllegalStateException("User ID not found in security context")
if (notification.recipient != null && notification.recipient?.keycloakId != currentUserKeycloakId) {
throw IllegalArgumentException("Cannot mark notification as read for another user")
}
notification.isRead = true
return notificationRepository.save(notification)
}
@Transactional
fun clearAllNotifications(
recipientKeycloakId: String,
organizationId: String,
) {
notificationRepository.deleteAllByRecipientAndOrganization(recipientKeycloakId, organizationId)
}
}

View File

@@ -17,13 +17,23 @@ class CustomJwtAuthenticationConverter : Converter<Jwt, AbstractAuthenticationTo
val legalconsenthubResource = resourceAccess?.get("legalconsenthub") as? Map<*, *>
val roles = (legalconsenthubResource?.get("roles") as? List<*>)?.mapNotNull { it as? String } ?: emptyList()
val organizationIds = extractOrganizationIds(jwt)
val authorities: Collection<GrantedAuthority> =
roles.map { role ->
SimpleGrantedAuthority("ROLE_$role")
}
val principal = CustomJwtTokenPrincipal(userId, username, roles)
val principal = CustomJwtTokenPrincipal(userId, username, roles, organizationIds)
return CustomJwtAuthentication(jwt, principal, authorities)
}
private fun extractOrganizationIds(jwt: Jwt): List<String> {
val organizationClaim = jwt.getClaimAsMap("organization") ?: return emptyList()
return organizationClaim.values.mapNotNull { meta ->
(meta as? Map<*, *>)?.get("id") as? String
}
}
}

View File

@@ -4,5 +4,5 @@ data class CustomJwtTokenPrincipal(
val id: String? = null,
val name: String? = null,
val roles: List<String> = emptyList(),
val organizationId: String? = null,
val organizationIds: List<String> = emptyList(),
)