feat(fullstack): Set user roles per orga, scope notification to orga and role, add orga and role to JWT

This commit is contained in:
2025-09-15 19:23:06 +02:00
parent 83f1fa71b6
commit e3643d8318
25 changed files with 575 additions and 287 deletions

View File

@@ -394,6 +394,35 @@ paths:
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServerError"
"503":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServiceUnavailable"
put:
summary: Update a user
operationId: updateUser
description: Updates a user. If no request body is provided, the user data will be synchronized from JWT token claims.
tags:
- user
requestBody:
required: false
content:
application/json:
schema:
$ref: "#/components/schemas/UserDto"
responses:
"200":
description: User successfully updated
content:
application/json:
schema:
$ref: "#/components/schemas/UserDto"
"400":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/BadRequest"
"401":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/Unauthorized"
"404":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/NotFound"
"500":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServerError"
"503":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServiceUnavailable"
delete:
summary: Delete a user
operationId: deleteUser
@@ -1049,6 +1078,7 @@ components:
- id
- name
- status
- organizationRoles
properties:
id:
type: string
@@ -1056,8 +1086,13 @@ components:
type: string
status:
$ref: "#/components/schemas/UserStatus"
role:
$ref: "#/components/schemas/UserRole"
organizationRoles:
type: object
additionalProperties:
type: array
items:
$ref: "#/components/schemas/UserRole"
description: "Map of organization IDs to arrays of user roles in those organizations"
CreateUserDto:
type: object
@@ -1065,7 +1100,6 @@ components:
- id
- name
- status
- role
properties:
id:
type: string
@@ -1073,8 +1107,13 @@ components:
type: string
status:
$ref: "#/components/schemas/UserStatus"
role:
$ref: "#/components/schemas/UserRole"
organizationRoles:
type: object
additionalProperties:
type: array
items:
$ref: "#/components/schemas/UserRole"
description: "Map of organization IDs to arrays of user roles in those organizations"
UserStatus:
type: string
@@ -1176,9 +1215,10 @@ components:
- message
- clickTarget
- isRead
- targetGroup
- role
- type
- createdAt
- organizationId
properties:
id:
type: string
@@ -1195,10 +1235,12 @@ components:
nullable: true
allOf:
- $ref: "#/components/schemas/UserDto"
targetGroup:
role:
type: string
type:
$ref: "#/components/schemas/NotificationType"
organizationId:
type: string
createdAt:
type: string
format: date-time
@@ -1209,8 +1251,9 @@ components:
- title
- message
- clickTarget
- targetGroup
- role
- type
- organizationId
properties:
title:
type: string
@@ -1222,10 +1265,12 @@ components:
nullable: true
allOf:
- $ref: "#/components/schemas/UserDto"
targetGroup:
role:
type: string
type:
$ref: "#/components/schemas/NotificationType"
organizationId:
type: string
PagedNotificationDto:
type: object

View File

@@ -96,44 +96,19 @@ class ApplicationFormService(
val message = "Ein neuer Mitbestimmungsantrag '${applicationForm.name}' wurde von ${applicationForm.createdBy.name} eingereicht und wartet auf Ihre Bearbeitung."
val clickTarget = "/application-forms/${applicationForm.id}/0"
// Create notification for admin users
notificationService.createNotificationForUser(
title = title,
message = message,
clickTarget = clickTarget,
recipient = null,
targetGroup = "admin",
type = NotificationType.INFO
)
// Create separate notification for each role that should be notified
val rolesToNotify = listOf("admin", "works_council_member", "employer", "employee")
// Create notification for works council members
notificationService.createNotificationForUser(
title = title,
message = message,
clickTarget = clickTarget,
recipient = null,
targetGroup = "works_council_member",
type = NotificationType.INFO
)
// Create notification for employer
notificationService.createNotificationForUser(
title = title,
message = message,
clickTarget = clickTarget,
recipient = null,
targetGroup = "employer",
type = NotificationType.INFO
)
// Create notification for employee
notificationService.createNotificationForUser(
title = title,
message = message,
clickTarget = clickTarget,
recipient = null,
targetGroup = "employee",
type = NotificationType.INFO
)
rolesToNotify.forEach { role ->
notificationService.createNotification(
title = title,
message = message,
clickTarget = clickTarget,
recipient = null,
role = role,
organizationId = applicationForm.organizationId,
type = NotificationType.INFO
)
}
}
}

View File

@@ -1,5 +1,6 @@
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
@@ -20,14 +21,12 @@ class SecurityConfig {
@Order(1)
fun publicApiSecurityFilterChain(http: HttpSecurity): SecurityFilterChain {
http {
securityMatcher("/swagger-ui/**", "/v3/**", "/actuator/**", "/users")
securityMatcher("/swagger-ui/**", "/v3/**", "/actuator/**")
csrf { disable() }
authorizeHttpRequests {
authorize("/swagger-ui/**", permitAll)
authorize("/v3/**", permitAll)
authorize("/actuator/**", permitAll)
// For user registration
authorize(HttpMethod.POST, "/users", permitAll)
authorize(anyRequest, denyAll)
}
}
@@ -43,6 +42,8 @@ class SecurityConfig {
http {
csrf { disable() }
authorizeHttpRequests {
// Allow user registration without authentication
authorize(HttpMethod.POST, "/users", permitAll)
authorize(anyRequest, authenticated)
}
oauth2ResourceServer {

View File

@@ -40,12 +40,15 @@ class Notification(
var recipient: User?,
@Column(nullable = false)
var targetGroup: String = "",
var role: String = "",
@Enumerated(EnumType.STRING)
@Column(nullable = false)
var type: NotificationType = NotificationType.INFO,
@Column(nullable = false)
var organizationId: String = "",
@CreatedDate
@Column(nullable = false)
var createdAt: LocalDateTime? = null

View File

@@ -1,7 +1,6 @@
package com.betriebsratkanzlei.legalconsenthub.notification
import com.betriebsratkanzlei.legalconsenthub.security.CustomJwtTokenPrincipal
import com.betriebsratkanzlei.legalconsenthub.user.UserService
import com.betriebsratkanzlei.legalconsenthub_api.api.NotificationApi
import com.betriebsratkanzlei.legalconsenthub_api.model.CreateNotificationDto
import com.betriebsratkanzlei.legalconsenthub_api.model.NotificationDto
@@ -15,30 +14,20 @@ import java.util.UUID
class NotificationController(
private val notificationService: NotificationService,
private val notificationMapper: NotificationMapper,
private val pagedNotificationMapper: PagedNotificationMapper,
private val userService: UserService
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 user = userService.getUserById(recipientId)
val notifications = if (user.role != null) {
notificationService.getNotificationsForUserAndGroup(
recipientId = recipientId,
userRole = user.role!!.value,
page = page,
size = size
)
} else {
notificationService.getNotificationsForUser(
recipientId = recipientId,
page = page,
size = size
)
}
val notifications = notificationService.getNotifications(
recipientId = recipientId,
organizationId = organizationId,
page = page,
size = size
)
return ResponseEntity.ok(pagedNotificationMapper.toPagedNotificationDto(notifications))
}
@@ -46,29 +35,25 @@ class NotificationController(
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 user = userService.getUserById(recipientId)
val notifications = if (user.role != null) {
notificationService.getUnreadNotificationsForUserAndGroup(recipientId, user.role!!.value)
} else {
notificationService.getUnreadNotificationsForUser(recipientId)
}
val notifications = notificationService.getUnreadNotifications(
recipientId = recipientId,
organizationId = organizationId
)
return ResponseEntity.ok(notifications.map { notificationMapper.toNotificationDto(it) })
}
override fun getUnreadNotificationCount(): ResponseEntity<Long> {
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 user = userService.getUserById(recipientId)
val count = if (user.role != null) {
notificationService.getUnreadNotificationCountForUserAndGroup(recipientId, user.role!!.value)
} else {
notificationService.getUnreadNotificationCount(recipientId)
}
val count = notificationService.getUnreadNotificationCount(
recipientId = recipientId,
organizationId = organizationId
)
return ResponseEntity.ok(count)
}
@@ -76,22 +61,18 @@ class NotificationController(
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")
val user = userService.getUserById(recipientId)
if (user.role != null) {
notificationService.markAllAsReadForUserAndGroup(recipientId, user.role!!.value)
} else {
notificationService.markAllAsRead(recipientId)
}
notificationService.markAllAsRead(
recipientId = recipientId,
organizationId = organizationId
)
return ResponseEntity.noContent().build()
}
override fun markNotificationAsRead(id: UUID): ResponseEntity<NotificationDto> {
val notification = notificationService.markAsRead(id)
?: return ResponseEntity.notFound().build()
val notification = notificationService.markNotificationAsRead(id)
return ResponseEntity.ok(notificationMapper.toNotificationDto(notification))
}

View File

@@ -18,8 +18,9 @@ class NotificationMapper(
clickTarget = notification.clickTarget,
isRead = notification.isRead,
recipient = notification.recipient?.let { userMapper.toUserDto(it) },
targetGroup = notification.targetGroup,
role = notification.role,
type = notification.type,
organizationId = notification.organizationId,
createdAt = notification.createdAt!!
)
}
@@ -31,8 +32,9 @@ class NotificationMapper(
clickTarget = createNotificationDto.clickTarget,
isRead = false,
recipient = createNotificationDto.recipient?.let { userMapper.toUser(it) },
targetGroup = createNotificationDto.targetGroup,
type = createNotificationDto.type
role = createNotificationDto.role,
type = createNotificationDto.type,
organizationId = createNotificationDto.organizationId
)
}
}

View File

@@ -13,35 +13,64 @@ import java.util.UUID
interface NotificationRepository : JpaRepository<Notification, UUID> {
fun findByRecipientIdOrderByCreatedAtDesc(recipientId: String?, pageable: Pageable): Page<Notification>
fun findByRecipientIdAndIsReadFalseOrderByCreatedAtDesc(recipientId: String?): List<Notification>
fun countByRecipientIdAndIsReadFalse(recipientId: String?): Long
fun findByRecipientIsNullOrderByCreatedAtDesc(pageable: Pageable): Page<Notification>
@Query("""
SELECT n FROM Notification n WHERE
(n.recipient.id = :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>
fun findByRecipientIsNullAndIsReadFalseOrderByCreatedAtDesc(): List<Notification>
@Query("""
SELECT n FROM Notification n WHERE
((n.recipient.id = :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 n FROM Notification n WHERE (n.recipient.id = :recipientId) OR (n.recipient IS NULL AND n.targetGroup = :targetGroup) OR (n.recipient IS NULL AND n.targetGroup = 'all') ORDER BY n.createdAt DESC")
fun findByRecipientIdOrTargetGroupOrderByCreatedAtDesc(@Param("recipientId") recipientId: String, @Param("targetGroup") targetGroup: String, pageable: Pageable): Page<Notification>
@Query("""
SELECT COUNT(n) FROM Notification n WHERE
((n.recipient.id = :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
@Query("SELECT n FROM Notification n WHERE ((n.recipient.id = :recipientId) OR (n.recipient IS NULL AND n.targetGroup = :targetGroup) OR (n.recipient IS NULL AND n.targetGroup = 'all')) AND n.isRead = false ORDER BY n.createdAt DESC")
fun findByRecipientIdOrTargetGroupAndIsReadFalseOrderByCreatedAtDesc(@Param("recipientId") recipientId: String, @Param("targetGroup") targetGroup: String): List<Notification>
@Query("SELECT COUNT(n) FROM Notification n WHERE ((n.recipient.id = :recipientId) OR (n.recipient IS NULL AND n.targetGroup = :targetGroup) OR (n.recipient IS NULL AND n.targetGroup = 'all')) AND n.isRead = false")
fun countByRecipientIdOrTargetGroupAndIsReadFalse(@Param("recipientId") recipientId: String, @Param("targetGroup") targetGroup: String): Long
@Modifying
@Query("""
UPDATE Notification n SET n.isRead = true WHERE
(n.recipient.id = :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.id = :recipientId")
fun markAllAsReadByRecipientId(@Param("recipientId") recipientId: String)
@Modifying
@Query("UPDATE Notification n SET n.isRead = true WHERE n.recipient IS NULL")
fun markAllAsReadForNullRecipients()
@Modifying
@Query("UPDATE Notification n SET n.isRead = true WHERE (n.recipient.id = :recipientId) OR (n.recipient IS NULL AND n.targetGroup = :targetGroup) OR (n.recipient IS NULL AND n.targetGroup = 'all')")
fun markAllAsReadByRecipientIdOrTargetGroup(@Param("recipientId") recipientId: String, @Param("targetGroup") targetGroup: String)
fun countByRecipientIdAndIsReadFalse(recipientId: String?): Long
fun countByRecipientIsNullAndIsReadFalse(): Long
}

View File

@@ -1,6 +1,8 @@
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
@@ -12,7 +14,9 @@ import java.util.UUID
@Service
class NotificationService(
private val notificationRepository: NotificationRepository,
private val notificationMapper: NotificationMapper
private val notificationMapper: NotificationMapper,
private val userRepository: UserRepository,
private val userRoleConverter: UserRoleConverter
) {
fun createNotification(createNotificationDto: CreateNotificationDto): Notification {
@@ -20,12 +24,13 @@ class NotificationService(
return notificationRepository.save(notification)
}
fun createNotificationForUser(
fun createNotification(
title: String,
message: String,
clickTarget: String,
recipient: User?,
targetGroup: String,
role: String,
organizationId: String,
type: NotificationType = NotificationType.INFO
): Notification {
val notification = Notification(
@@ -33,55 +38,90 @@ class NotificationService(
message = message,
clickTarget = clickTarget,
recipient = recipient,
targetGroup = targetGroup,
type = type
role = role,
type = type,
organizationId = organizationId
)
return notificationRepository.save(notification)
}
fun getNotificationsForUser(recipientId: String, page: Int = 0, size: Int = 20): Page<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 notificationRepository.findByRecipientIdOrderByCreatedAtDesc(recipientId, pageable)
}
fun getNotificationsForUserAndGroup(recipientId: String, userRole: String, page: Int = 0, size: Int = 20): Page<Notification> {
val pageable = PageRequest.of(page, size)
return notificationRepository.findByRecipientIdOrTargetGroupOrderByCreatedAtDesc(recipientId, userRole, pageable)
}
fun getUnreadNotificationsForUser(recipientId: String): List<Notification> {
return notificationRepository.findByRecipientIdAndIsReadFalseOrderByCreatedAtDesc(recipientId)
}
fun getUnreadNotificationsForUserAndGroup(recipientId: String, userRole: String): List<Notification> {
return notificationRepository.findByRecipientIdOrTargetGroupAndIsReadFalseOrderByCreatedAtDesc(recipientId, userRole)
}
fun getUnreadNotificationCount(recipientId: String): Long {
return notificationRepository.countByRecipientIdAndIsReadFalse(recipientId)
}
fun getUnreadNotificationCountForUserAndGroup(recipientId: String, userRole: String): Long {
return notificationRepository.countByRecipientIdOrTargetGroupAndIsReadFalse(recipientId, userRole)
}
@Transactional
fun markAllAsRead(recipientId: String) {
notificationRepository.markAllAsReadByRecipientId(recipientId)
}
@Transactional
fun markAllAsReadForUserAndGroup(recipientId: String, userRole: String) {
notificationRepository.markAllAsReadByRecipientIdOrTargetGroup(recipientId, userRole)
}
fun markAsRead(notificationId: UUID): Notification? {
val notification = notificationRepository.findById(notificationId).orElse(null)
return if (notification != null) {
notification.isRead = true
notificationRepository.save(notification)
return if (userRoles.isNotEmpty()) {
notificationRepository.findUserNotificationsByOrgRole(recipientId, listOf(organizationId), orgRolePairs, pageable)
} else {
null
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)
}
}

View File

@@ -1,7 +1,5 @@
package com.betriebsratkanzlei.legalconsenthub.config
package com.betriebsratkanzlei.legalconsenthub.security
import com.betriebsratkanzlei.legalconsenthub.security.CustomJwtAuthentication
import com.betriebsratkanzlei.legalconsenthub.security.CustomJwtTokenPrincipal
import org.springframework.core.convert.converter.Converter
import org.springframework.security.authentication.AbstractAuthenticationToken
import org.springframework.security.core.GrantedAuthority
@@ -15,7 +13,10 @@ class CustomJwtAuthenticationConverter : Converter<Jwt, AbstractAuthenticationTo
val userId = jwt.getClaimAsString("id")
val username = jwt.getClaimAsString("name")
val principal = CustomJwtTokenPrincipal(userId, username)
val organizationId = jwt.getClaimAsString("organizationId")
val roles = jwt.getClaimAsStringList("roles") ?: emptyList()
val principal = CustomJwtTokenPrincipal(userId, username, organizationId, roles)
return CustomJwtAuthentication(jwt, principal, authorities)
}

View File

@@ -2,5 +2,7 @@ package com.betriebsratkanzlei.legalconsenthub.security
data class CustomJwtTokenPrincipal(
val id: String? = null,
val name: String? = null
val name: String? = null,
val organizationId: String? = null,
val roles: List<String> = emptyList()
)

View File

@@ -1,6 +1,5 @@
package com.betriebsratkanzlei.legalconsenthub.user
import com.betriebsratkanzlei.legalconsenthub_api.model.UserRole
import com.betriebsratkanzlei.legalconsenthub_api.model.UserStatus
import jakarta.persistence.*
import org.springframework.data.annotation.CreatedDate
@@ -22,10 +21,10 @@ class User(
@Enumerated(EnumType.STRING)
@Column(nullable = false)
var status: UserStatus = UserStatus.ACTIVE,
@Enumerated(EnumType.STRING)
@Column(nullable = true)
var role: UserRole? = null,
@ElementCollection
@CollectionTable(name = "user_organization_roles", joinColumns = [JoinColumn(name = "user_id")])
var organizationRoles: MutableSet<UserOrganizationRole> = mutableSetOf(),
@CreatedDate
@Column(nullable = false)

View File

@@ -1,9 +1,11 @@
package com.betriebsratkanzlei.legalconsenthub.user
import com.betriebsratkanzlei.legalconsenthub.security.CustomJwtTokenPrincipal
import com.betriebsratkanzlei.legalconsenthub_api.api.UserApi
import com.betriebsratkanzlei.legalconsenthub_api.model.CreateUserDto
import com.betriebsratkanzlei.legalconsenthub_api.model.UserDto
import org.springframework.http.ResponseEntity
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.web.bind.annotation.RestController
@RestController
@@ -22,8 +24,24 @@ class UserController(
return ResponseEntity.ok(userMapper.toUserDto(user))
}
override fun updateUser(id: String, userDto: UserDto?): ResponseEntity<UserDto> {
val user = if (userDto != null) {
// Update with provided data
userService.updateUser(id, userDto)
} else {
// Update from JWT data
val principal = SecurityContextHolder.getContext().authentication.principal as CustomJwtTokenPrincipal
val userId = principal.id ?: throw IllegalArgumentException("User ID missing from JWT")
val organizationId = principal.organizationId
val roles = principal.roles
userService.updateUserFromJwt(userId, organizationId, roles)
}
return ResponseEntity.ok(userMapper.toUserDto(user))
}
override fun deleteUser(id: String): ResponseEntity<Unit> {
userService.deleteUser(id)
return ResponseEntity.noContent().build()
}
}
}

View File

@@ -4,22 +4,31 @@ import com.betriebsratkanzlei.legalconsenthub_api.model.UserDto
import org.springframework.stereotype.Component
@Component
class UserMapper() {
class UserMapper(
private val roleConverter: UserRoleConverter
) {
fun toUserDto(user: User): UserDto {
val organizationRolesDto = roleConverter.convertToMap(user.organizationRoles)
return UserDto(
id = user.id,
name = user.name,
status = user.status,
role = user.role
organizationRoles = organizationRolesDto
)
}
fun toUser(userDto: UserDto): User {
return User(
val user = User(
id = userDto.id,
name = userDto.name,
status = userDto.status,
role = userDto.role
status = userDto.status
)
userDto.organizationRoles.forEach { (orgId, roles) ->
roleConverter.setRolesForOrganization(user.organizationRoles, orgId, roles)
}
return user
}
}

View File

@@ -0,0 +1,13 @@
package com.betriebsratkanzlei.legalconsenthub.user
import jakarta.persistence.Column
import jakarta.persistence.Embeddable
@Embeddable
data class UserOrganizationRole(
@Column(name = "organization_id", nullable = false)
val organizationId: String,
@Column(name = "role", nullable = false)
val role: String
)

View File

@@ -0,0 +1,42 @@
package com.betriebsratkanzlei.legalconsenthub.user
import com.betriebsratkanzlei.legalconsenthub_api.model.UserRole
import org.springframework.stereotype.Component
@Component
object UserRoleConverter {
fun getRolesForOrganization(organizationRoles: Set<UserOrganizationRole>, organizationId: String): List<UserRole> {
return organizationRoles
.filter { it.organizationId == organizationId }
.mapNotNull { orgRole ->
try {
UserRole.valueOf(orgRole.role)
} catch (e: IllegalArgumentException) {
null
}
}
}
fun setRolesForOrganization(organizationRoles: MutableSet<UserOrganizationRole>, organizationId: String, roles: List<UserRole>) {
organizationRoles.removeIf { it.organizationId == organizationId }
roles.forEach { role ->
organizationRoles.add(UserOrganizationRole(organizationId, role.name))
}
}
fun convertToMap(organizationRoles: Set<UserOrganizationRole>): Map<String, List<UserRole>> {
return organizationRoles
.groupBy { it.organizationId }
.mapValues { (_, roles) ->
roles.mapNotNull { orgRole ->
try {
UserRole.valueOf(orgRole.role)
} catch (e: IllegalArgumentException) {
null
}
}
}
.filterValues { it.isNotEmpty() }
}
}

View File

@@ -4,13 +4,16 @@ import com.betriebsratkanzlei.legalconsenthub.error.UserAlreadyExistsException
import com.betriebsratkanzlei.legalconsenthub.error.UserNotFoundException
import com.betriebsratkanzlei.legalconsenthub.security.CustomJwtTokenPrincipal
import com.betriebsratkanzlei.legalconsenthub_api.model.CreateUserDto
import com.betriebsratkanzlei.legalconsenthub_api.model.UserDto
import com.betriebsratkanzlei.legalconsenthub_api.model.UserStatus
import jakarta.transaction.Transactional
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.stereotype.Service
@Service
class UserService(
private val userRepository: UserRepository
private val userRepository: UserRepository,
private val roleConverter: UserRoleConverter
) {
fun getCurrentUser(): User {
@@ -29,9 +32,13 @@ class UserService(
val user = User(
id = createUserDto.id,
name = createUserDto.name,
status = createUserDto.status ?: UserStatus.ACTIVE,
role = createUserDto.role
status = createUserDto.status
)
createUserDto.organizationRoles?.forEach { (orgId, roles) ->
roleConverter.setRolesForOrganization(user.organizationRoles, orgId, roles)
}
return userRepository.save(user)
}
@@ -40,6 +47,44 @@ class UserService(
.orElseThrow { UserNotFoundException(userId) }
}
@Transactional
fun updateUser(userId: String, userDto: UserDto): User {
val user = userRepository.findById(userId)
.orElseThrow { UserNotFoundException(userId) }
user.name = userDto.name
user.status = userDto.status
user.organizationRoles.clear()
userDto.organizationRoles.forEach { (orgId, roles) ->
roleConverter.setRolesForOrganization(user.organizationRoles, orgId, roles)
}
return userRepository.save(user)
}
@Transactional
fun updateUserFromJwt(userId: String, jwtOrganizationId: String?, jwtRoles: List<String>?): User {
val existingUser = userRepository.findById(userId)
.orElseThrow { UserNotFoundException(userId) }
if (jwtOrganizationId != null && !jwtRoles.isNullOrEmpty()) {
existingUser.organizationRoles.removeIf { it.organizationId == jwtOrganizationId }
jwtRoles.forEach { role ->
val normalizedRole = role.lowercase().replace("_", "_")
existingUser.organizationRoles.add(
UserOrganizationRole(
organizationId = jwtOrganizationId,
role = normalizedRole
)
)
}
}
return userRepository.save(existingUser)
}
fun deleteUser(userId: String) {
userRepository.deleteById(userId)
}

View File

@@ -4,7 +4,6 @@ create table app_user
modified_at timestamp(6) not null,
id varchar(255) not null,
name varchar(255) not null,
role varchar(255),
status varchar(255) not null check (status in ('INVITED', 'ACTIVE', 'BLOCKED', 'SUSPENDED_SUBSCRIPTION')),
primary key (id)
);
@@ -66,18 +65,27 @@ create table form_element_section
create table notification
(
is_read boolean not null,
created_at timestamp(6) not null,
id uuid not null,
click_target varchar(255) not null,
message TEXT not null,
recipient_id varchar(255),
target_group varchar(255) not null,
title varchar(255) not null,
type varchar(255) not null check (type in ('INFO', 'WARNING', 'ERROR')),
is_read boolean not null,
created_at timestamp(6) not null,
id uuid not null,
click_target varchar(255) not null,
message TEXT not null,
organization_id varchar(255) not null,
recipient_id varchar(255),
role varchar(255) not null,
title varchar(255) not null,
type varchar(255) not null check (type in ('INFO', 'WARNING', 'ERROR')),
primary key (id)
);
create table user_organization_roles
(
organization_id varchar(255) not null,
role varchar(255) not null,
user_id varchar(255) not null,
primary key (organization_id, role, user_id)
);
alter table if exists application_form
add constraint FKhtad5onoy2jknhtyfmx6cvvey
foreign key (created_by_id)
@@ -122,3 +130,8 @@ alter table if exists notification
add constraint FKeg1j4hnp0y4lbm0y35hgr4e8r
foreign key (recipient_id)
references app_user;
alter table if exists user_organization_roles
add constraint FKhgmm93qre3up6hy63wcef3yqk
foreign key (user_id)
references app_user