From db788c4ee3e137d62ce928079b9d2aae157edf84 Mon Sep 17 00:00:00 2001 From: Denis Lugowski Date: Sun, 18 Jan 2026 18:42:10 +0100 Subject: [PATCH] feat(#36): Notification rework with single and all comments mark as read --- api/legalconsenthub.yml | 56 +++++-- .../LegalconsenthubApplication.kt | 2 + .../ApplicationFormService.kt | 42 +++++ .../legalconsenthub/comment/CommentService.kt | 111 ++++++++++++++ .../email/ApplicationFormUpdatedEvent.kt | 11 ++ .../email/CommentAddedEvent.kt | 12 ++ .../email/EmailEventListener.kt | 65 ++++++++ .../legalconsenthub/email/EmailService.kt | 26 ++++ .../notification/Notification.kt | 2 + .../notification/NotificationController.kt | 14 +- .../notification/NotificationMapper.kt | 1 + .../NotificationPurgeScheduler.kt | 27 ++++ .../notification/NotificationRepository.kt | 13 ++ .../notification/NotificationService.kt | 39 +++++ .../legalconsenthub/user/User.kt | 4 + .../legalconsenthub/user/UserController.kt | 2 + .../legalconsenthub/user/UserMapper.kt | 4 + .../legalconsenthub/user/UserRepository.kt | 4 + .../legalconsenthub/user/UserService.kt | 4 + .../templates/email/comment_added.html | 38 +++++ .../templates/email/form_updated.html | 34 ++++ .../app/components/NotificationsSlideover.vue | 145 ++++++++++++------ legalconsenthub/app/composables/index.ts | 1 - .../notification/useNotificationApi.ts | 11 +- .../app/composables/user/useUser.ts | 12 +- legalconsenthub/app/layouts/default.vue | 11 +- legalconsenthub/app/pages/index.vue | 7 +- legalconsenthub/app/pages/settings.vue | 10 +- legalconsenthub/i18n/locales/de.json | 7 +- legalconsenthub/i18n/locales/en.json | 7 +- .../useNotificationStore.ts} | 83 +++++++--- 31 files changed, 711 insertions(+), 94 deletions(-) create mode 100644 legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/email/ApplicationFormUpdatedEvent.kt create mode 100644 legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/email/CommentAddedEvent.kt create mode 100644 legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/notification/NotificationPurgeScheduler.kt create mode 100644 legalconsenthub-backend/src/main/resources/templates/email/comment_added.html create mode 100644 legalconsenthub-backend/src/main/resources/templates/email/form_updated.html rename legalconsenthub/{app/composables/notification/useNotification.ts => stores/useNotificationStore.ts} (62%) diff --git a/api/legalconsenthub.yml b/api/legalconsenthub.yml index a0cb03b..e206d21 100644 --- a/api/legalconsenthub.yml +++ b/api/legalconsenthub.yml @@ -885,6 +885,18 @@ paths: $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" + delete: + summary: Clear all notifications for the current user + operationId: clearAllNotifications + tags: + - notification + responses: + "204": + description: All notifications cleared + "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" /notifications/unread: get: @@ -968,26 +980,28 @@ paths: "500": $ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServerError" - /notifications/clear-all: + /notifications/{id}: + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid delete: - summary: Clear all notifications for the current user - operationId: clearAllNotifications + summary: Delete a specific notification + operationId: deleteNotification 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 + description: Notification successfully deleted + "404": + $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 + description: User is not authorized to delete this notification "500": $ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServerError" @@ -1636,6 +1650,14 @@ components: emailOnFormSubmitted: type: boolean default: true + emailOnFormUpdated: + type: boolean + default: true + description: Whether to receive email when someone else updates a form the user created + emailOnCommentAdded: + type: boolean + default: true + description: Whether to receive email when someone comments on a form the user created UpdateEmailPreferencesDto: type: object @@ -1647,6 +1669,12 @@ components: type: boolean emailOnFormSubmitted: type: boolean + emailOnFormUpdated: + type: boolean + description: Whether to receive email when someone else updates a form the user created + emailOnCommentAdded: + type: boolean + description: Whether to receive email when someone comments on a form the user created UserStatus: type: string @@ -1823,6 +1851,10 @@ components: 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 + excludedUserId: + type: string + description: Keycloak ID of user to exclude from receiving this notification (e.g., the action performer). + nullable: true type: $ref: "#/components/schemas/NotificationType" organizationId: diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/LegalconsenthubApplication.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/LegalconsenthubApplication.kt index 7f2fcb1..0eea97b 100644 --- a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/LegalconsenthubApplication.kt +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/LegalconsenthubApplication.kt @@ -4,10 +4,12 @@ import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.runApplication import org.springframework.data.jpa.repository.config.EnableJpaAuditing import org.springframework.scheduling.annotation.EnableAsync +import org.springframework.scheduling.annotation.EnableScheduling @SpringBootApplication @EnableJpaAuditing @EnableAsync +@EnableScheduling class LegalconsenthubApplication fun main(args: Array) { diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form/ApplicationFormService.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form/ApplicationFormService.kt index cc3d55a..9f896bc 100644 --- a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form/ApplicationFormService.kt +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form/ApplicationFormService.kt @@ -4,6 +4,7 @@ import com.betriebsratkanzlei.legalconsenthub.application_form_version.Applicati import com.betriebsratkanzlei.legalconsenthub.comment.CommentRepository import com.betriebsratkanzlei.legalconsenthub.email.ApplicationFormCreatedEvent import com.betriebsratkanzlei.legalconsenthub.email.ApplicationFormSubmittedEvent +import com.betriebsratkanzlei.legalconsenthub.email.ApplicationFormUpdatedEvent import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormInvalidStateException import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotCreatedException import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotDeletedException @@ -115,6 +116,11 @@ class ApplicationFormService( if (existingSnapshot != newSnapshot) { versionService.createVersion(updatedApplicationForm, currentUser) + + // Notify the form author if someone else made changes + if (updatedApplicationForm.createdBy.keycloakId != currentUser.keycloakId) { + createNotificationForFormUpdate(updatedApplicationForm, currentUser.name) + } } return updatedApplicationForm @@ -180,6 +186,7 @@ class ApplicationFormService( clickTarget = clickTarget, recipientId = null, targetRoles = null, + excludedUserId = applicationForm.createdBy.keycloakId, type = NotificationType.INFO, organizationId = applicationForm.organizationId, ) @@ -187,6 +194,41 @@ class ApplicationFormService( notificationService.createNotificationForOrganization(createNotificationDto) } + private fun createNotificationForFormUpdate( + applicationForm: ApplicationForm, + modifierName: String, + ) { + val title = "Änderungen an Ihrem Mitbestimmungsantrag" + val message = + "$modifierName hat Änderungen an Ihrem Mitbestimmungsantrag '${applicationForm.name}' vorgenommen." + val clickTarget = "/application-forms/${applicationForm.id}/0" + + val createNotificationDto = + CreateNotificationDto( + title = title, + message = message, + clickTarget = clickTarget, + recipientId = applicationForm.createdBy.keycloakId, + targetRoles = null, + excludedUserId = null, + type = NotificationType.INFO, + organizationId = applicationForm.organizationId, + ) + + notificationService.createNotificationForUser(createNotificationDto) + + // Publish email event for form author + eventPublisher.publishEvent( + ApplicationFormUpdatedEvent( + applicationFormId = applicationForm.id!!, + organizationId = applicationForm.organizationId, + updaterName = modifierName, + formName = applicationForm.name, + authorKeycloakId = applicationForm.createdBy.keycloakId, + ), + ) + } + fun addFormElementToSubSection( applicationFormId: UUID, subsectionId: UUID, diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/comment/CommentService.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/comment/CommentService.kt index e612c0a..364dece 100644 --- a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/comment/CommentService.kt +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/comment/CommentService.kt @@ -1,12 +1,20 @@ package com.betriebsratkanzlei.legalconsenthub.comment import com.betriebsratkanzlei.legalconsenthub.application_form.ApplicationFormRepository +import com.betriebsratkanzlei.legalconsenthub.email.CommentAddedEvent +import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotFoundException import com.betriebsratkanzlei.legalconsenthub.error.CommentNotCreatedException import com.betriebsratkanzlei.legalconsenthub.error.CommentNotDeletedException import com.betriebsratkanzlei.legalconsenthub.error.CommentNotFoundException import com.betriebsratkanzlei.legalconsenthub.error.CommentNotUpdatedException +import com.betriebsratkanzlei.legalconsenthub.notification.NotificationService import com.betriebsratkanzlei.legalconsenthub_api.model.CommentDto import com.betriebsratkanzlei.legalconsenthub_api.model.CreateCommentDto +import com.betriebsratkanzlei.legalconsenthub_api.model.CreateNotificationDto +import com.betriebsratkanzlei.legalconsenthub_api.model.NotificationType +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import org.springframework.context.ApplicationEventPublisher import org.springframework.data.domain.PageRequest import org.springframework.stereotype.Service import java.time.Instant @@ -23,6 +31,9 @@ class CommentService( private val commentRepository: CommentRepository, private val applicationFormRepository: ApplicationFormRepository, private val commentMapper: CommentMapper, + private val notificationService: NotificationService, + private val eventPublisher: ApplicationEventPublisher, + private val objectMapper: ObjectMapper, ) { fun createComment( applicationFormId: UUID, @@ -37,9 +48,70 @@ class CommentService( throw CommentNotCreatedException(e) } + // Notify the form author if someone else added a comment + val applicationForm = + applicationFormRepository + .findById(applicationFormId) + .orElseThrow { ApplicationFormNotFoundException(applicationFormId) } + + if (applicationForm.createdBy.keycloakId != savedComment.createdBy.keycloakId) { + createNotificationForNewComment( + savedComment, + applicationForm.createdBy.keycloakId, + applicationForm.organizationId, + ) + } + return savedComment } + private fun createNotificationForNewComment( + comment: Comment, + formAuthorKeycloakId: String, + organizationId: String, + ) { + val formName = comment.applicationForm?.name ?: "Unbekannt" + val title = "Neuer Kommentar zu Ihrem Mitbestimmungsantrag" + val message = + "${comment.createdBy.name} hat einen Kommentar zu Ihrem Mitbestimmungsantrag " + + "'$formName' hinzugefügt." + val clickTarget = "/application-forms/${comment.applicationForm?.id}/0" + + val createNotificationDto = + CreateNotificationDto( + title = title, + message = message, + clickTarget = clickTarget, + recipientId = formAuthorKeycloakId, + targetRoles = null, + excludedUserId = null, + type = NotificationType.INFO, + organizationId = organizationId, + ) + + notificationService.createNotificationForUser(createNotificationDto) + + // Publish email event for form author + val plainText = extractPlainTextFromTipTap(comment.message) + val commentPreview = + if (plainText.length > 100) { + plainText.take(100) + "..." + } else { + plainText + } + + eventPublisher.publishEvent( + CommentAddedEvent( + applicationFormId = comment.applicationForm?.id!!, + organizationId = organizationId, + commenterName = comment.createdBy.name, + formName = formName, + authorKeycloakId = formAuthorKeycloakId, + commentPreview = commentPreview, + ), + ) + } + fun getCommentById(id: UUID): Comment = commentRepository.findById(id).orElseThrow { CommentNotFoundException(id) } fun getComments( @@ -100,4 +172,43 @@ class CommentService( throw CommentNotDeletedException(e) } } + + /** + * Extracts plain text from TipTap/ProseMirror JSON content. + * TipTap stores content as JSON like: {"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Hello"}]}]} + */ + private fun extractPlainTextFromTipTap(jsonContent: String): String { + return try { + val rootNode = objectMapper.readTree(jsonContent) + val textBuilder = StringBuilder() + extractTextRecursively(rootNode, textBuilder) + textBuilder.toString().trim() + } catch (e: Exception) { + // If parsing fails, return the original content (might be plain text) + jsonContent + } + } + + private fun extractTextRecursively( + node: JsonNode, + builder: StringBuilder, + ) { + when { + node.has("text") -> { + builder.append(node.get("text").asText()) + } + node.has("content") -> { + val content = node.get("content") + if (content.isArray) { + content.forEachIndexed { index, child -> + extractTextRecursively(child, builder) + // Add newline between paragraphs + if (child.has("type") && child.get("type").asText() == "paragraph" && index < content.size() - 1) { + builder.append("\n") + } + } + } + } + } + } } diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/email/ApplicationFormUpdatedEvent.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/email/ApplicationFormUpdatedEvent.kt new file mode 100644 index 0000000..f5a163f --- /dev/null +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/email/ApplicationFormUpdatedEvent.kt @@ -0,0 +1,11 @@ +package com.betriebsratkanzlei.legalconsenthub.email + +import java.util.UUID + +data class ApplicationFormUpdatedEvent( + val applicationFormId: UUID, + val organizationId: String?, + val updaterName: String, + val formName: String, + val authorKeycloakId: String, +) diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/email/CommentAddedEvent.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/email/CommentAddedEvent.kt new file mode 100644 index 0000000..e3ff6af --- /dev/null +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/email/CommentAddedEvent.kt @@ -0,0 +1,12 @@ +package com.betriebsratkanzlei.legalconsenthub.email + +import java.util.UUID + +data class CommentAddedEvent( + val applicationFormId: UUID, + val organizationId: String?, + val commenterName: String, + val formName: String, + val authorKeycloakId: String, + val commentPreview: String, +) diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/email/EmailEventListener.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/email/EmailEventListener.kt index c79604f..606a06d 100644 --- a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/email/EmailEventListener.kt +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/email/EmailEventListener.kt @@ -70,4 +70,69 @@ class EmailEventListener( ) } } + + @Async + @EventListener + fun handleApplicationFormUpdated(event: ApplicationFormUpdatedEvent) { + logger.info("Processing ApplicationFormUpdatedEvent for form: ${event.formName}") + + val recipient = + userRepository + .findByKeycloakIdAndEmailOnFormUpdatedTrue(event.authorKeycloakId) + ?.takeIf { !it.email.isNullOrBlank() } + + if (recipient == null) { + logger.info("No recipient found for form updated event (author has email disabled or no email)") + return + } + + logger.info("Sending form updated email to author: ${recipient.email}") + + val subject = "Ihr Mitbestimmungsantrag wurde aktualisiert: ${event.formName}" + val body = + emailService.buildFormUpdatedEmail( + formName = event.formName, + updaterName = event.updaterName, + applicationFormId = event.applicationFormId, + ) + + emailService.sendEmail( + to = recipient.email!!, + subject = subject, + body = body, + ) + } + + @Async + @EventListener + fun handleCommentAdded(event: CommentAddedEvent) { + logger.info("Processing CommentAddedEvent for form: ${event.formName}") + + val recipient = + userRepository + .findByKeycloakIdAndEmailOnCommentAddedTrue(event.authorKeycloakId) + ?.takeIf { !it.email.isNullOrBlank() } + + if (recipient == null) { + logger.info("No recipient found for comment added event (author has email disabled or no email)") + return + } + + logger.info("Sending comment added email to author: ${recipient.email}") + + val subject = "Neuer Kommentar zu Ihrem Antrag: ${event.formName}" + val body = + emailService.buildCommentAddedEmail( + formName = event.formName, + commenterName = event.commenterName, + applicationFormId = event.applicationFormId, + commentPreview = event.commentPreview, + ) + + emailService.sendEmail( + to = recipient.email!!, + subject = subject, + body = body, + ) + } } diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/email/EmailService.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/email/EmailService.kt index 6471a9f..4a72906 100644 --- a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/email/EmailService.kt +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/email/EmailService.kt @@ -60,4 +60,30 @@ class EmailService( context.setVariable("applicationFormId", applicationFormId) return templateEngine.process("email/form_submitted", context) } + + fun buildFormUpdatedEmail( + formName: String, + updaterName: String, + applicationFormId: UUID, + ): String { + val context = Context() + context.setVariable("formName", formName) + context.setVariable("updaterName", updaterName) + context.setVariable("applicationFormId", applicationFormId) + return templateEngine.process("email/form_updated", context) + } + + fun buildCommentAddedEmail( + formName: String, + commenterName: String, + applicationFormId: UUID, + commentPreview: String, + ): String { + val context = Context() + context.setVariable("formName", formName) + context.setVariable("commenterName", commenterName) + context.setVariable("applicationFormId", applicationFormId) + context.setVariable("commentPreview", commentPreview) + return templateEngine.process("email/comment_added", context) + } } diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/notification/Notification.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/notification/Notification.kt index 77d6731..c152b32 100644 --- a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/notification/Notification.kt +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/notification/Notification.kt @@ -35,6 +35,8 @@ class Notification( var recipient: User? = null, @Column(nullable = true, columnDefinition = "TEXT") var targetRoles: String? = null, + @Column(nullable = true) + var excludedUserId: String? = null, @Enumerated(EnumType.STRING) @Column(nullable = false) var type: NotificationType = NotificationType.INFO, diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/notification/NotificationController.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/notification/NotificationController.kt index bcf4a5a..5fe5abd 100644 --- a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/notification/NotificationController.kt +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/notification/NotificationController.kt @@ -128,10 +128,12 @@ class NotificationController( @PreAuthorize( "hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL', 'EMPLOYEE')", ) - override fun clearAllNotifications(organizationId: String): ResponseEntity { + override fun clearAllNotifications(): ResponseEntity { val principal = SecurityContextHolder.getContext().authentication.principal as CustomJwtTokenPrincipal val recipientId = principal.id ?: throw IllegalStateException("User ID not found") - validateOrganizationAccess(principal, organizationId) + val organizationId = + principal.organizationIds.firstOrNull() + ?: throw IllegalStateException("User has no organization") notificationService.clearAllNotifications( recipientKeycloakId = recipientId, @@ -141,6 +143,14 @@ class NotificationController( return ResponseEntity.noContent().build() } + @PreAuthorize( + "hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL', 'EMPLOYEE')", + ) + override fun deleteNotification(id: UUID): ResponseEntity { + notificationService.deleteNotification(id) + return ResponseEntity.noContent().build() + } + private fun validateOrganizationAccess( principal: CustomJwtTokenPrincipal, organizationId: String, diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/notification/NotificationMapper.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/notification/NotificationMapper.kt index 9102835..8b4af94 100644 --- a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/notification/NotificationMapper.kt +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/notification/NotificationMapper.kt @@ -39,6 +39,7 @@ class NotificationMapper( clickTarget = createNotificationDto.clickTarget, recipient = recipient, targetRoles = targetRoles, + excludedUserId = createNotificationDto.excludedUserId, type = createNotificationDto.type, organizationId = createNotificationDto.organizationId, ) diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/notification/NotificationPurgeScheduler.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/notification/NotificationPurgeScheduler.kt new file mode 100644 index 0000000..7df2795 --- /dev/null +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/notification/NotificationPurgeScheduler.kt @@ -0,0 +1,27 @@ +package com.betriebsratkanzlei.legalconsenthub.notification + +import org.slf4j.LoggerFactory +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Component + +@Component +class NotificationPurgeScheduler( + private val notificationService: NotificationService, +) { + private val logger = LoggerFactory.getLogger(NotificationPurgeScheduler::class.java) + + /** + * Purges read notifications older than 90 days. + * Runs daily at 2:00 AM. + */ + @Scheduled(cron = "0 0 2 * * *") + fun purgeOldReadNotifications() { + logger.info("Starting scheduled purge of old read notifications") + try { + notificationService.purgeOldReadNotifications(daysOld = 90) + logger.info("Successfully purged old read notifications") + } catch (e: Exception) { + logger.error("Failed to purge old read notifications", e) + } + } +} diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/notification/NotificationRepository.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/notification/NotificationRepository.kt index 2ad915f..04e39a5 100644 --- a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/notification/NotificationRepository.kt +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/notification/NotificationRepository.kt @@ -7,6 +7,7 @@ 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.time.Instant import java.util.UUID @Repository @@ -77,4 +78,16 @@ interface NotificationRepository : JpaRepository { @Param("keycloakId") keycloakId: String, @Param("organizationId") organizationId: String, ) + + @Modifying + @Query( + """ + DELETE FROM Notification n + WHERE n.isRead = true + AND n.createdAt < :cutoffDate + """, + ) + fun deleteReadNotificationsOlderThan( + @Param("cutoffDate") cutoffDate: Instant, + ) } diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/notification/NotificationService.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/notification/NotificationService.kt index 95fe1dd..18a8436 100644 --- a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/notification/NotificationService.kt +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/notification/NotificationService.kt @@ -77,6 +77,11 @@ class NotificationService( val filteredContent = allNotifications.content.filter { notification -> + // Exclude notifications where the current user is the excluded user + if (notification.excludedUserId == recipientKeycloakId) { + return@filter false + } + when { // Direct recipient notification notification.recipient != null -> true @@ -105,6 +110,11 @@ class NotificationService( ) return allNotifications.filter { notification -> + // Exclude notifications where the current user is the excluded user + if (notification.excludedUserId == recipientKeycloakId) { + return@filter false + } + when { // Direct recipient notification notification.recipient != null -> true @@ -160,4 +170,33 @@ class NotificationService( ) { notificationRepository.deleteAllByRecipientAndOrganization(recipientKeycloakId, organizationId) } + + @Transactional + fun deleteNotification(notificationId: UUID) { + 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") + + // Allow deletion if user is the recipient or if it's an organization-wide notification + if (notification.recipient != null && notification.recipient?.keycloakId != currentUserKeycloakId) { + throw IllegalArgumentException("Cannot delete notification for another user") + } + + notificationRepository.delete(notification) + } + + @Transactional + fun purgeOldReadNotifications(daysOld: Int = 90) { + val cutoffDate = + java.time.Instant + .now() + .minus(java.time.Duration.ofDays(daysOld.toLong())) + notificationRepository.deleteReadNotificationsOlderThan(cutoffDate) + } } diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/user/User.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/user/User.kt index daba5c7..31b27fe 100644 --- a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/user/User.kt +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/user/User.kt @@ -27,6 +27,10 @@ class User( var emailOnFormCreated: Boolean = true, @Column(nullable = false) var emailOnFormSubmitted: Boolean = true, + @Column(nullable = false) + var emailOnFormUpdated: Boolean = true, + @Column(nullable = false) + var emailOnCommentAdded: Boolean = true, @CreatedDate @Column(nullable = false) var createdAt: Instant? = null, diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/user/UserController.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/user/UserController.kt index 3413763..0fa1e43 100644 --- a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/user/UserController.kt +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/user/UserController.kt @@ -31,6 +31,8 @@ class UserController( email = updateEmailPreferencesDto.email, emailOnFormCreated = updateEmailPreferencesDto.emailOnFormCreated ?: true, emailOnFormSubmitted = updateEmailPreferencesDto.emailOnFormSubmitted ?: true, + emailOnFormUpdated = updateEmailPreferencesDto.emailOnFormUpdated ?: true, + emailOnCommentAdded = updateEmailPreferencesDto.emailOnCommentAdded ?: true, ) return ResponseEntity.ok(userMapper.toUserDto(user)) } diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/user/UserMapper.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/user/UserMapper.kt index 268ca3d..31e01d0 100644 --- a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/user/UserMapper.kt +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/user/UserMapper.kt @@ -13,6 +13,8 @@ class UserMapper { email = user.email, emailOnFormCreated = user.emailOnFormCreated, emailOnFormSubmitted = user.emailOnFormSubmitted, + emailOnFormUpdated = user.emailOnFormUpdated, + emailOnCommentAdded = user.emailOnCommentAdded, ) fun toUser(userDto: UserDto): User { @@ -24,6 +26,8 @@ class UserMapper { email = userDto.email, emailOnFormCreated = userDto.emailOnFormCreated ?: true, emailOnFormSubmitted = userDto.emailOnFormSubmitted ?: true, + emailOnFormUpdated = userDto.emailOnFormUpdated ?: true, + emailOnCommentAdded = userDto.emailOnCommentAdded ?: true, ) return user diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/user/UserRepository.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/user/UserRepository.kt index e8e3796..799bd99 100644 --- a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/user/UserRepository.kt +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/user/UserRepository.kt @@ -8,4 +8,8 @@ interface UserRepository : JpaRepository { fun findByOrganizationIdAndEmailOnFormCreatedTrue(organizationId: String?): List fun findByOrganizationIdAndEmailOnFormSubmittedTrue(organizationId: String?): List + + fun findByKeycloakIdAndEmailOnFormUpdatedTrue(keycloakId: String): User? + + fun findByKeycloakIdAndEmailOnCommentAddedTrue(keycloakId: String): User? } diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/user/UserService.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/user/UserService.kt index 08a3d0f..e762626 100644 --- a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/user/UserService.kt +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/user/UserService.kt @@ -77,12 +77,16 @@ class UserService( email: String?, emailOnFormCreated: Boolean, emailOnFormSubmitted: Boolean, + emailOnFormUpdated: Boolean, + emailOnCommentAdded: Boolean, ): User { val user = userRepository.findById(userId).orElseThrow { UserNotFoundException(userId) } user.email = email user.emailOnFormCreated = emailOnFormCreated user.emailOnFormSubmitted = emailOnFormSubmitted + user.emailOnFormUpdated = emailOnFormUpdated + user.emailOnCommentAdded = emailOnCommentAdded return userRepository.save(user) } diff --git a/legalconsenthub-backend/src/main/resources/templates/email/comment_added.html b/legalconsenthub-backend/src/main/resources/templates/email/comment_added.html new file mode 100644 index 0000000..346f6ec --- /dev/null +++ b/legalconsenthub-backend/src/main/resources/templates/email/comment_added.html @@ -0,0 +1,38 @@ + + + + + + + +
+
+

Neuer Kommentar zu Ihrem Antrag

+
+
+

Hallo,

+

Es wurde ein neuer Kommentar zu Ihrem Mitbestimmungsantrag hinzugefügt:

+
    +
  • Antrag:
  • +
  • Kommentar von:
  • +
+
+ +
+

Klicken Sie auf den folgenden Link, um den Kommentar anzusehen:

+ Kommentar ansehen +
+ +
+ + diff --git a/legalconsenthub-backend/src/main/resources/templates/email/form_updated.html b/legalconsenthub-backend/src/main/resources/templates/email/form_updated.html new file mode 100644 index 0000000..8c7e2c7 --- /dev/null +++ b/legalconsenthub-backend/src/main/resources/templates/email/form_updated.html @@ -0,0 +1,34 @@ + + + + + + + +
+
+

Mitbestimmungsantrag aktualisiert

+
+
+

Hallo,

+

Ihr Mitbestimmungsantrag wurde von einer anderen Person aktualisiert:

+
    +
  • Antrag:
  • +
  • Aktualisiert von:
  • +
+

Klicken Sie auf den folgenden Link, um die Änderungen anzusehen:

+ Änderungen ansehen +
+ +
+ + diff --git a/legalconsenthub/app/components/NotificationsSlideover.vue b/legalconsenthub/app/components/NotificationsSlideover.vue index f98e0a7..2027dbf 100644 --- a/legalconsenthub/app/components/NotificationsSlideover.vue +++ b/legalconsenthub/app/components/NotificationsSlideover.vue @@ -1,61 +1,98 @@ @@ -63,6 +100,9 @@ diff --git a/legalconsenthub/app/composables/index.ts b/legalconsenthub/app/composables/index.ts index ee9961c..229ab0f 100644 --- a/legalconsenthub/app/composables/index.ts +++ b/legalconsenthub/app/composables/index.ts @@ -2,7 +2,6 @@ export { useApplicationFormTemplate } from './applicationFormTemplate/useApplica export { useApplicationForm } from './applicationForm/useApplicationForm' export { useApplicationFormVersion } from './applicationFormVersion/useApplicationFormVersion' export { useApplicationFormVersionApi } from './applicationFormVersion/useApplicationFormVersionApi' -export { useNotification } from './notification/useNotification' export { useNotificationApi } from './notification/useNotificationApi' export { useUser } from './user/useUser' export { useUserApi } from './user/useUserApi' diff --git a/legalconsenthub/app/composables/notification/useNotificationApi.ts b/legalconsenthub/app/composables/notification/useNotificationApi.ts index 27e03e7..d821c62 100644 --- a/legalconsenthub/app/composables/notification/useNotificationApi.ts +++ b/legalconsenthub/app/composables/notification/useNotificationApi.ts @@ -44,8 +44,12 @@ export function useNotificationApi() { return notificationApiClient.markNotificationAsRead({ id, organizationId }) } - async function clearAllNotifications(organizationId: string): Promise { - return notificationApiClient.clearAllNotifications({ organizationId }) + async function clearAllNotifications(): Promise { + return notificationApiClient.clearAllNotifications() + } + + async function deleteNotification(id: string): Promise { + return notificationApiClient.deleteNotification({ id }) } return { @@ -55,6 +59,7 @@ export function useNotificationApi() { getUnreadNotificationCount, markAllNotificationsAsRead, markNotificationAsRead, - clearAllNotifications + clearAllNotifications, + deleteNotification } } diff --git a/legalconsenthub/app/composables/user/useUser.ts b/legalconsenthub/app/composables/user/useUser.ts index 1d74b6f..d73a7c8 100644 --- a/legalconsenthub/app/composables/user/useUser.ts +++ b/legalconsenthub/app/composables/user/useUser.ts @@ -12,9 +12,17 @@ export function useUser() { userId: string, email: string | null, emailOnFormCreated: boolean, - emailOnFormSubmitted: boolean + emailOnFormSubmitted: boolean, + emailOnFormUpdated: boolean, + emailOnCommentAdded: boolean ): Promise { - const updateDto: UpdateEmailPreferencesDto = { email, emailOnFormCreated, emailOnFormSubmitted } + const updateDto: UpdateEmailPreferencesDto = { + email, + emailOnFormCreated, + emailOnFormSubmitted, + emailOnFormUpdated, + emailOnCommentAdded + } return await userApi.updateEmailPreferences(userId, updateDto) } diff --git a/legalconsenthub/app/layouts/default.vue b/legalconsenthub/app/layouts/default.vue index aa48773..e06af88 100644 --- a/legalconsenthub/app/layouts/default.vue +++ b/legalconsenthub/app/layouts/default.vue @@ -36,6 +36,8 @@