feat(#36): Notification rework with single and all comments mark as read

This commit is contained in:
2026-01-18 18:42:10 +01:00
parent 105baf7c86
commit db788c4ee3
31 changed files with 711 additions and 94 deletions

View File

@@ -885,6 +885,18 @@ paths:
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/Unauthorized" $ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/Unauthorized"
"500": "500":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServerError" $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: /notifications/unread:
get: get:
@@ -968,26 +980,28 @@ paths:
"500": "500":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServerError" $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: delete:
summary: Clear all notifications for the current user summary: Delete a specific notification
operationId: clearAllNotifications operationId: deleteNotification
tags: tags:
- notification - notification
parameters:
- in: query
name: organizationId
required: true
schema:
type: string
description: Organization ID to clear notifications for
responses: responses:
"204": "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": "401":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/Unauthorized" $ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/Unauthorized"
"403": "403":
description: User is not authorized to access this organization description: User is not authorized to delete this notification
"500": "500":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServerError" $ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServerError"
@@ -1636,6 +1650,14 @@ components:
emailOnFormSubmitted: emailOnFormSubmitted:
type: boolean type: boolean
default: true 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: UpdateEmailPreferencesDto:
type: object type: object
@@ -1647,6 +1669,12 @@ components:
type: boolean type: boolean
emailOnFormSubmitted: emailOnFormSubmitted:
type: boolean 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: UserStatus:
type: string type: string
@@ -1823,6 +1851,10 @@ components:
type: string 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. 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 nullable: true
excludedUserId:
type: string
description: Keycloak ID of user to exclude from receiving this notification (e.g., the action performer).
nullable: true
type: type:
$ref: "#/components/schemas/NotificationType" $ref: "#/components/schemas/NotificationType"
organizationId: organizationId:

View File

@@ -4,10 +4,12 @@ import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication import org.springframework.boot.runApplication
import org.springframework.data.jpa.repository.config.EnableJpaAuditing import org.springframework.data.jpa.repository.config.EnableJpaAuditing
import org.springframework.scheduling.annotation.EnableAsync import org.springframework.scheduling.annotation.EnableAsync
import org.springframework.scheduling.annotation.EnableScheduling
@SpringBootApplication @SpringBootApplication
@EnableJpaAuditing @EnableJpaAuditing
@EnableAsync @EnableAsync
@EnableScheduling
class LegalconsenthubApplication class LegalconsenthubApplication
fun main(args: Array<String>) { fun main(args: Array<String>) {

View File

@@ -4,6 +4,7 @@ import com.betriebsratkanzlei.legalconsenthub.application_form_version.Applicati
import com.betriebsratkanzlei.legalconsenthub.comment.CommentRepository import com.betriebsratkanzlei.legalconsenthub.comment.CommentRepository
import com.betriebsratkanzlei.legalconsenthub.email.ApplicationFormCreatedEvent import com.betriebsratkanzlei.legalconsenthub.email.ApplicationFormCreatedEvent
import com.betriebsratkanzlei.legalconsenthub.email.ApplicationFormSubmittedEvent import com.betriebsratkanzlei.legalconsenthub.email.ApplicationFormSubmittedEvent
import com.betriebsratkanzlei.legalconsenthub.email.ApplicationFormUpdatedEvent
import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormInvalidStateException import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormInvalidStateException
import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotCreatedException import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotCreatedException
import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotDeletedException import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotDeletedException
@@ -115,6 +116,11 @@ class ApplicationFormService(
if (existingSnapshot != newSnapshot) { if (existingSnapshot != newSnapshot) {
versionService.createVersion(updatedApplicationForm, currentUser) 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 return updatedApplicationForm
@@ -180,6 +186,7 @@ class ApplicationFormService(
clickTarget = clickTarget, clickTarget = clickTarget,
recipientId = null, recipientId = null,
targetRoles = null, targetRoles = null,
excludedUserId = applicationForm.createdBy.keycloakId,
type = NotificationType.INFO, type = NotificationType.INFO,
organizationId = applicationForm.organizationId, organizationId = applicationForm.organizationId,
) )
@@ -187,6 +194,41 @@ class ApplicationFormService(
notificationService.createNotificationForOrganization(createNotificationDto) 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( fun addFormElementToSubSection(
applicationFormId: UUID, applicationFormId: UUID,
subsectionId: UUID, subsectionId: UUID,

View File

@@ -1,12 +1,20 @@
package com.betriebsratkanzlei.legalconsenthub.comment package com.betriebsratkanzlei.legalconsenthub.comment
import com.betriebsratkanzlei.legalconsenthub.application_form.ApplicationFormRepository 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.CommentNotCreatedException
import com.betriebsratkanzlei.legalconsenthub.error.CommentNotDeletedException import com.betriebsratkanzlei.legalconsenthub.error.CommentNotDeletedException
import com.betriebsratkanzlei.legalconsenthub.error.CommentNotFoundException import com.betriebsratkanzlei.legalconsenthub.error.CommentNotFoundException
import com.betriebsratkanzlei.legalconsenthub.error.CommentNotUpdatedException 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.CommentDto
import com.betriebsratkanzlei.legalconsenthub_api.model.CreateCommentDto 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.data.domain.PageRequest
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import java.time.Instant import java.time.Instant
@@ -23,6 +31,9 @@ class CommentService(
private val commentRepository: CommentRepository, private val commentRepository: CommentRepository,
private val applicationFormRepository: ApplicationFormRepository, private val applicationFormRepository: ApplicationFormRepository,
private val commentMapper: CommentMapper, private val commentMapper: CommentMapper,
private val notificationService: NotificationService,
private val eventPublisher: ApplicationEventPublisher,
private val objectMapper: ObjectMapper,
) { ) {
fun createComment( fun createComment(
applicationFormId: UUID, applicationFormId: UUID,
@@ -37,9 +48,70 @@ class CommentService(
throw CommentNotCreatedException(e) 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 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 getCommentById(id: UUID): Comment = commentRepository.findById(id).orElseThrow { CommentNotFoundException(id) }
fun getComments( fun getComments(
@@ -100,4 +172,43 @@ class CommentService(
throw CommentNotDeletedException(e) 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")
}
}
}
}
}
}
} }

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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,
)
}
} }

View File

@@ -60,4 +60,30 @@ class EmailService(
context.setVariable("applicationFormId", applicationFormId) context.setVariable("applicationFormId", applicationFormId)
return templateEngine.process("email/form_submitted", context) 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)
}
} }

View File

@@ -35,6 +35,8 @@ class Notification(
var recipient: User? = null, var recipient: User? = null,
@Column(nullable = true, columnDefinition = "TEXT") @Column(nullable = true, columnDefinition = "TEXT")
var targetRoles: String? = null, var targetRoles: String? = null,
@Column(nullable = true)
var excludedUserId: String? = null,
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(nullable = false) @Column(nullable = false)
var type: NotificationType = NotificationType.INFO, var type: NotificationType = NotificationType.INFO,

View File

@@ -128,10 +128,12 @@ class NotificationController(
@PreAuthorize( @PreAuthorize(
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL', 'EMPLOYEE')", "hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL', 'EMPLOYEE')",
) )
override fun clearAllNotifications(organizationId: String): ResponseEntity<Unit> { override fun clearAllNotifications(): ResponseEntity<Unit> {
val principal = SecurityContextHolder.getContext().authentication.principal as CustomJwtTokenPrincipal val principal = SecurityContextHolder.getContext().authentication.principal as CustomJwtTokenPrincipal
val recipientId = principal.id ?: throw IllegalStateException("User ID not found") 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( notificationService.clearAllNotifications(
recipientKeycloakId = recipientId, recipientKeycloakId = recipientId,
@@ -141,6 +143,14 @@ class NotificationController(
return ResponseEntity.noContent().build() 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<Unit> {
notificationService.deleteNotification(id)
return ResponseEntity.noContent().build()
}
private fun validateOrganizationAccess( private fun validateOrganizationAccess(
principal: CustomJwtTokenPrincipal, principal: CustomJwtTokenPrincipal,
organizationId: String, organizationId: String,

View File

@@ -39,6 +39,7 @@ class NotificationMapper(
clickTarget = createNotificationDto.clickTarget, clickTarget = createNotificationDto.clickTarget,
recipient = recipient, recipient = recipient,
targetRoles = targetRoles, targetRoles = targetRoles,
excludedUserId = createNotificationDto.excludedUserId,
type = createNotificationDto.type, type = createNotificationDto.type,
organizationId = createNotificationDto.organizationId, organizationId = createNotificationDto.organizationId,
) )

View File

@@ -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)
}
}
}

View File

@@ -7,6 +7,7 @@ import org.springframework.data.jpa.repository.Modifying
import org.springframework.data.jpa.repository.Query import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param import org.springframework.data.repository.query.Param
import org.springframework.stereotype.Repository import org.springframework.stereotype.Repository
import java.time.Instant
import java.util.UUID import java.util.UUID
@Repository @Repository
@@ -77,4 +78,16 @@ interface NotificationRepository : JpaRepository<Notification, UUID> {
@Param("keycloakId") keycloakId: String, @Param("keycloakId") keycloakId: String,
@Param("organizationId") organizationId: 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,
)
} }

View File

@@ -77,6 +77,11 @@ class NotificationService(
val filteredContent = val filteredContent =
allNotifications.content.filter { notification -> allNotifications.content.filter { notification ->
// Exclude notifications where the current user is the excluded user
if (notification.excludedUserId == recipientKeycloakId) {
return@filter false
}
when { when {
// Direct recipient notification // Direct recipient notification
notification.recipient != null -> true notification.recipient != null -> true
@@ -105,6 +110,11 @@ class NotificationService(
) )
return allNotifications.filter { notification -> return allNotifications.filter { notification ->
// Exclude notifications where the current user is the excluded user
if (notification.excludedUserId == recipientKeycloakId) {
return@filter false
}
when { when {
// Direct recipient notification // Direct recipient notification
notification.recipient != null -> true notification.recipient != null -> true
@@ -160,4 +170,33 @@ class NotificationService(
) { ) {
notificationRepository.deleteAllByRecipientAndOrganization(recipientKeycloakId, organizationId) 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)
}
} }

View File

@@ -27,6 +27,10 @@ class User(
var emailOnFormCreated: Boolean = true, var emailOnFormCreated: Boolean = true,
@Column(nullable = false) @Column(nullable = false)
var emailOnFormSubmitted: Boolean = true, var emailOnFormSubmitted: Boolean = true,
@Column(nullable = false)
var emailOnFormUpdated: Boolean = true,
@Column(nullable = false)
var emailOnCommentAdded: Boolean = true,
@CreatedDate @CreatedDate
@Column(nullable = false) @Column(nullable = false)
var createdAt: Instant? = null, var createdAt: Instant? = null,

View File

@@ -31,6 +31,8 @@ class UserController(
email = updateEmailPreferencesDto.email, email = updateEmailPreferencesDto.email,
emailOnFormCreated = updateEmailPreferencesDto.emailOnFormCreated ?: true, emailOnFormCreated = updateEmailPreferencesDto.emailOnFormCreated ?: true,
emailOnFormSubmitted = updateEmailPreferencesDto.emailOnFormSubmitted ?: true, emailOnFormSubmitted = updateEmailPreferencesDto.emailOnFormSubmitted ?: true,
emailOnFormUpdated = updateEmailPreferencesDto.emailOnFormUpdated ?: true,
emailOnCommentAdded = updateEmailPreferencesDto.emailOnCommentAdded ?: true,
) )
return ResponseEntity.ok(userMapper.toUserDto(user)) return ResponseEntity.ok(userMapper.toUserDto(user))
} }

View File

@@ -13,6 +13,8 @@ class UserMapper {
email = user.email, email = user.email,
emailOnFormCreated = user.emailOnFormCreated, emailOnFormCreated = user.emailOnFormCreated,
emailOnFormSubmitted = user.emailOnFormSubmitted, emailOnFormSubmitted = user.emailOnFormSubmitted,
emailOnFormUpdated = user.emailOnFormUpdated,
emailOnCommentAdded = user.emailOnCommentAdded,
) )
fun toUser(userDto: UserDto): User { fun toUser(userDto: UserDto): User {
@@ -24,6 +26,8 @@ class UserMapper {
email = userDto.email, email = userDto.email,
emailOnFormCreated = userDto.emailOnFormCreated ?: true, emailOnFormCreated = userDto.emailOnFormCreated ?: true,
emailOnFormSubmitted = userDto.emailOnFormSubmitted ?: true, emailOnFormSubmitted = userDto.emailOnFormSubmitted ?: true,
emailOnFormUpdated = userDto.emailOnFormUpdated ?: true,
emailOnCommentAdded = userDto.emailOnCommentAdded ?: true,
) )
return user return user

View File

@@ -8,4 +8,8 @@ interface UserRepository : JpaRepository<User, String> {
fun findByOrganizationIdAndEmailOnFormCreatedTrue(organizationId: String?): List<User> fun findByOrganizationIdAndEmailOnFormCreatedTrue(organizationId: String?): List<User>
fun findByOrganizationIdAndEmailOnFormSubmittedTrue(organizationId: String?): List<User> fun findByOrganizationIdAndEmailOnFormSubmittedTrue(organizationId: String?): List<User>
fun findByKeycloakIdAndEmailOnFormUpdatedTrue(keycloakId: String): User?
fun findByKeycloakIdAndEmailOnCommentAddedTrue(keycloakId: String): User?
} }

View File

@@ -77,12 +77,16 @@ class UserService(
email: String?, email: String?,
emailOnFormCreated: Boolean, emailOnFormCreated: Boolean,
emailOnFormSubmitted: Boolean, emailOnFormSubmitted: Boolean,
emailOnFormUpdated: Boolean,
emailOnCommentAdded: Boolean,
): User { ): User {
val user = userRepository.findById(userId).orElseThrow { UserNotFoundException(userId) } val user = userRepository.findById(userId).orElseThrow { UserNotFoundException(userId) }
user.email = email user.email = email
user.emailOnFormCreated = emailOnFormCreated user.emailOnFormCreated = emailOnFormCreated
user.emailOnFormSubmitted = emailOnFormSubmitted user.emailOnFormSubmitted = emailOnFormSubmitted
user.emailOnFormUpdated = emailOnFormUpdated
user.emailOnCommentAdded = emailOnCommentAdded
return userRepository.save(user) return userRepository.save(user)
} }

View File

@@ -0,0 +1,38 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background-color: #3B82F6; color: white; padding: 20px; border-radius: 5px 5px 0 0; }
.content { background-color: #f9fafb; padding: 20px; }
.comment-preview { background-color: #e5e7eb; padding: 15px; border-radius: 5px; margin: 15px 0; font-style: italic; }
.button { display: inline-block; padding: 10px 20px; background-color: #3B82F6; color: white; text-decoration: none; border-radius: 5px; margin-top: 15px; }
.footer { color: #6b7280; font-size: 12px; margin-top: 20px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h2>Neuer Kommentar zu Ihrem Antrag</h2>
</div>
<div class="content">
<p>Hallo,</p>
<p>Es wurde ein neuer Kommentar zu Ihrem Mitbestimmungsantrag hinzugefügt:</p>
<ul>
<li><strong>Antrag:</strong> <span th:text="${formName}"></span></li>
<li><strong>Kommentar von:</strong> <span th:text="${commenterName}"></span></li>
</ul>
<div class="comment-preview">
<span th:text="${commentPreview}"></span>
</div>
<p>Klicken Sie auf den folgenden Link, um den Kommentar anzusehen:</p>
<a th:href="@{http://localhost:3001/application-forms/{id}/0(id=${applicationFormId})}" class="button">Kommentar ansehen</a>
</div>
<div class="footer">
<p>Diese E-Mail wurde automatisch von Legal Consent Hub gesendet.</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,34 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background-color: #F59E0B; color: white; padding: 20px; border-radius: 5px 5px 0 0; }
.content { background-color: #f9fafb; padding: 20px; }
.button { display: inline-block; padding: 10px 20px; background-color: #F59E0B; color: white; text-decoration: none; border-radius: 5px; margin-top: 15px; }
.footer { color: #6b7280; font-size: 12px; margin-top: 20px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h2>Mitbestimmungsantrag aktualisiert</h2>
</div>
<div class="content">
<p>Hallo,</p>
<p>Ihr Mitbestimmungsantrag wurde von einer anderen Person aktualisiert:</p>
<ul>
<li><strong>Antrag:</strong> <span th:text="${formName}"></span></li>
<li><strong>Aktualisiert von:</strong> <span th:text="${updaterName}"></span></li>
</ul>
<p>Klicken Sie auf den folgenden Link, um die Änderungen anzusehen:</p>
<a th:href="@{http://localhost:3001/application-forms/{id}/0(id=${applicationFormId})}" class="button">Änderungen ansehen</a>
</div>
<div class="footer">
<p>Diese E-Mail wurde automatisch von Legal Consent Hub gesendet.</p>
</div>
</div>
</body>
</html>

View File

@@ -1,61 +1,98 @@
<template> <template>
<USlideover v-model:open="isOpen" :title="$t('notifications.title')"> <USlideover v-model:open="isOpen" :title="$t('notifications.title')">
<template #body> <template #body>
<!-- Action buttons at the top -->
<div v-if="notifications.length > 0" class="flex gap-2 mb-4 -mt-2">
<UButton
:label="$t('notifications.markAllRead')"
icon="i-lucide-check-check"
color="neutral"
variant="outline"
size="sm"
:disabled="!hasUnreadNotifications"
@click="onMarkAllAsRead"
/>
<UButton
:label="$t('notifications.deleteAll')"
icon="i-lucide-trash-2"
color="error"
variant="outline"
size="sm"
@click="onDeleteAll"
/>
</div>
<div v-if="notifications.length === 0" class="text-center py-8 text-muted"> <div v-if="notifications.length === 0" class="text-center py-8 text-muted">
<UIcon name="i-heroicons-bell-slash" class="h-8 w-8 mx-auto mb-2" /> <UIcon name="i-heroicons-bell-slash" class="h-8 w-8 mx-auto mb-2" />
<p>{{ $t('notifications.empty') }}</p> <p>{{ $t('notifications.empty') }}</p>
</div> </div>
<NuxtLink <div
v-for="notification in notifications" v-for="notification in notifications"
:key="notification.id" :key="notification.id"
:to="notification.clickTarget" class="px-3 py-2.5 rounded-md hover:bg-elevated/50 flex items-center gap-3 relative -mx-3 first:-mt-3 last:-mb-3 group"
class="px-3 py-2.5 rounded-md hover:bg-elevated/50 flex items-center gap-3 relative -mx-3 first:-mt-3 last:-mb-3"
@click="onNotificationClick(notification)"
> >
<UChip <NuxtLink
:color="notification.type === 'ERROR' ? 'error' : notification.type === 'WARNING' ? 'warning' : 'primary'" :to="notification.clickTarget"
:show="!notification.isRead" class="flex items-center gap-3 flex-1"
inset @click="onNotificationClick(notification)"
> >
<UIcon <UChip
:name=" :color="notification.type === 'ERROR' ? 'error' : notification.type === 'WARNING' ? 'warning' : 'primary'"
notification.type === 'ERROR' :show="!notification.isRead"
? 'i-heroicons-x-circle' inset
: notification.type === 'WARNING' >
? 'i-heroicons-exclamation-triangle' <UIcon
: 'i-heroicons-information-circle' :name="
" notification.type === 'ERROR'
class="h-6 w-6" ? 'i-heroicons-x-circle'
/> : notification.type === 'WARNING'
</UChip> ? 'i-heroicons-exclamation-triangle'
: 'i-heroicons-information-circle'
<div class="text-sm flex-1"> "
<p class="flex items-center justify-between"> class="h-6 w-6"
<span class="text-highlighted font-medium">{{ notification.title }}</span>
<time
:datetime="notification.createdAt.toISOString()"
class="text-muted text-xs"
v-text="formatTimeAgo(notification.createdAt)"
/> />
</p> </UChip>
<p class="text-dimmed"> <div class="text-sm flex-1">
{{ notification.message }} <p class="flex items-center justify-between">
</p> <span class="text-highlighted font-medium">{{ notification.title }}</span>
<div class="flex items-center gap-2 mt-1"> <time
<UBadge :datetime="notification.createdAt.toISOString()"
:color="notification.type === 'ERROR' ? 'error' : notification.type === 'WARNING' ? 'warning' : 'info'" class="text-muted text-xs"
variant="subtle" v-text="formatTimeAgo(notification.createdAt)"
size="xs" />
> </p>
{{ notification.type }}
</UBadge> <p class="text-dimmed">
{{ notification.message }}
</p>
<div class="flex items-center gap-2 mt-1">
<UBadge
:color="notification.type === 'ERROR' ? 'error' : notification.type === 'WARNING' ? 'warning' : 'info'"
variant="subtle"
size="xs"
>
{{ notification.type }}
</UBadge>
</div>
</div> </div>
</div> </NuxtLink>
</NuxtLink>
<!-- Delete button for individual notification -->
<UButton
icon="i-lucide-x"
color="neutral"
variant="ghost"
size="xs"
square
class="opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
:aria-label="$t('notifications.delete')"
@click.stop.prevent="onDeleteNotification(notification.id)"
/>
</div>
</template> </template>
</USlideover> </USlideover>
</template> </template>
@@ -63,6 +100,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatTimeAgo } from '@vueuse/core' import { formatTimeAgo } from '@vueuse/core'
import type { NotificationDto } from '~~/.api-client' import type { NotificationDto } from '~~/.api-client'
import { useNotificationStore } from '~~/stores/useNotificationStore'
const { t: $t } = useI18n()
const emit = defineEmits<{ const emit = defineEmits<{
'update:modelValue': [value: boolean] 'update:modelValue': [value: boolean]
@@ -77,16 +117,31 @@ const isOpen = computed({
set: (value) => emit('update:modelValue', value) set: (value) => emit('update:modelValue', value)
}) })
const { notifications, fetchNotifications, handleNotificationClick } = useNotification() const notificationStore = useNotificationStore()
const { notifications } = storeToRefs(notificationStore)
const hasUnreadNotifications = computed(() => notifications.value.some((n) => !n.isRead))
watch(isOpen, async (newValue) => { watch(isOpen, async (newValue) => {
if (newValue) { if (newValue) {
await fetchNotifications() await notificationStore.fetchNotifications()
} }
}) })
function onNotificationClick(notification: NotificationDto) { function onNotificationClick(notification: NotificationDto) {
handleNotificationClick(notification) notificationStore.handleNotificationClick(notification)
emit('update:modelValue', false) emit('update:modelValue', false)
} }
async function onMarkAllAsRead() {
await notificationStore.markAllAsRead()
}
async function onDeleteAll() {
await notificationStore.deleteAllNotifications()
}
async function onDeleteNotification(notificationId: string) {
await notificationStore.deleteSingleNotification(notificationId)
}
</script> </script>

View File

@@ -2,7 +2,6 @@ export { useApplicationFormTemplate } from './applicationFormTemplate/useApplica
export { useApplicationForm } from './applicationForm/useApplicationForm' export { useApplicationForm } from './applicationForm/useApplicationForm'
export { useApplicationFormVersion } from './applicationFormVersion/useApplicationFormVersion' export { useApplicationFormVersion } from './applicationFormVersion/useApplicationFormVersion'
export { useApplicationFormVersionApi } from './applicationFormVersion/useApplicationFormVersionApi' export { useApplicationFormVersionApi } from './applicationFormVersion/useApplicationFormVersionApi'
export { useNotification } from './notification/useNotification'
export { useNotificationApi } from './notification/useNotificationApi' export { useNotificationApi } from './notification/useNotificationApi'
export { useUser } from './user/useUser' export { useUser } from './user/useUser'
export { useUserApi } from './user/useUserApi' export { useUserApi } from './user/useUserApi'

View File

@@ -44,8 +44,12 @@ export function useNotificationApi() {
return notificationApiClient.markNotificationAsRead({ id, organizationId }) return notificationApiClient.markNotificationAsRead({ id, organizationId })
} }
async function clearAllNotifications(organizationId: string): Promise<void> { async function clearAllNotifications(): Promise<void> {
return notificationApiClient.clearAllNotifications({ organizationId }) return notificationApiClient.clearAllNotifications()
}
async function deleteNotification(id: string): Promise<void> {
return notificationApiClient.deleteNotification({ id })
} }
return { return {
@@ -55,6 +59,7 @@ export function useNotificationApi() {
getUnreadNotificationCount, getUnreadNotificationCount,
markAllNotificationsAsRead, markAllNotificationsAsRead,
markNotificationAsRead, markNotificationAsRead,
clearAllNotifications clearAllNotifications,
deleteNotification
} }
} }

View File

@@ -12,9 +12,17 @@ export function useUser() {
userId: string, userId: string,
email: string | null, email: string | null,
emailOnFormCreated: boolean, emailOnFormCreated: boolean,
emailOnFormSubmitted: boolean emailOnFormSubmitted: boolean,
emailOnFormUpdated: boolean,
emailOnCommentAdded: boolean
): Promise<UserDto> { ): Promise<UserDto> {
const updateDto: UpdateEmailPreferencesDto = { email, emailOnFormCreated, emailOnFormSubmitted } const updateDto: UpdateEmailPreferencesDto = {
email,
emailOnFormCreated,
emailOnFormSubmitted,
emailOnFormUpdated,
emailOnCommentAdded
}
return await userApi.updateEmailPreferences(userId, updateDto) return await userApi.updateEmailPreferences(userId, updateDto)
} }

View File

@@ -36,6 +36,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useNotificationStore } from '~~/stores/useNotificationStore'
const { t: $t } = useI18n() const { t: $t } = useI18n()
const links = [ const links = [
@@ -60,16 +62,17 @@ const open = ref(false)
const logger = useLogger().withTag('layout') const logger = useLogger().withTag('layout')
const isNotificationsSlideoverOpen = ref(false) const isNotificationsSlideoverOpen = ref(false)
const { unreadCount, fetchUnreadCount, startPeriodicRefresh } = useNotification() const notificationStore = useNotificationStore()
const { hasUnread } = storeToRefs(notificationStore)
onMounted(async () => { onMounted(async () => {
await fetchUnreadCount() await notificationStore.fetchUnreadCount()
startPeriodicRefresh() notificationStore.startPeriodicRefresh()
}) })
provide('notificationState', { provide('notificationState', {
isNotificationsSlideoverOpen, isNotificationsSlideoverOpen,
unreadCount hasUnread
}) })
async function copyAccessTokenToClipboard() { async function copyAccessTokenToClipboard() {

View File

@@ -22,9 +22,8 @@
<UTooltip :text="$t('notifications.tooltip')" :shortcuts="['N']"> <UTooltip :text="$t('notifications.tooltip')" :shortcuts="['N']">
<UButton color="neutral" variant="ghost" square @click="isNotificationsSlideoverOpen = true"> <UButton color="neutral" variant="ghost" square @click="isNotificationsSlideoverOpen = true">
<UChip :show="unreadCount > 0" color="error" inset> <UChip :show="hasUnread" color="error" inset>
<UIcon name="i-lucide-bell" class="size-5 shrink-0" /> <UIcon name="i-lucide-bell" class="size-5 shrink-0" />
<span v-if="unreadCount > 0" class="ml-1 text-xs">{{ unreadCount }}</span>
</UChip> </UChip>
</UButton> </UButton>
</UTooltip> </UTooltip>
@@ -144,9 +143,9 @@ const { organizations, selectedOrganization } = storeToRefs(userStore)
const { t: $t } = useI18n() const { t: $t } = useI18n()
// Inject notification state from layout // Inject notification state from layout
const { isNotificationsSlideoverOpen, unreadCount } = inject('notificationState', { const { isNotificationsSlideoverOpen, hasUnread } = inject('notificationState', {
isNotificationsSlideoverOpen: ref(false), isNotificationsSlideoverOpen: ref(false),
unreadCount: ref(0) hasUnread: ref(false)
}) })
const { data } = await useAsyncData<PagedApplicationFormDto>( const { data } = await useAsyncData<PagedApplicationFormDto>(

View File

@@ -94,6 +94,8 @@
<div class="space-y-3"> <div class="space-y-3">
<UCheckbox v-model="emailOnFormCreated" :label="$t('settings.email.onFormCreated')" /> <UCheckbox v-model="emailOnFormCreated" :label="$t('settings.email.onFormCreated')" />
<UCheckbox v-model="emailOnFormSubmitted" :label="$t('settings.email.onFormSubmitted')" /> <UCheckbox v-model="emailOnFormSubmitted" :label="$t('settings.email.onFormSubmitted')" />
<UCheckbox v-model="emailOnFormUpdated" :label="$t('settings.email.onFormUpdated')" />
<UCheckbox v-model="emailOnCommentAdded" :label="$t('settings.email.onCommentAdded')" />
</div> </div>
<UButton :label="$t('common.save')" color="primary" :loading="isSaving" @click="saveEmailPreferences" /> <UButton :label="$t('common.save')" color="primary" :loading="isSaving" @click="saveEmailPreferences" />
@@ -143,6 +145,8 @@ const colors = [
const emailAddress = ref('') const emailAddress = ref('')
const emailOnFormCreated = ref(true) const emailOnFormCreated = ref(true)
const emailOnFormSubmitted = ref(true) const emailOnFormSubmitted = ref(true)
const emailOnFormUpdated = ref(true)
const emailOnCommentAdded = ref(true)
const isSaving = ref(false) const isSaving = ref(false)
onMounted(async () => { onMounted(async () => {
@@ -152,6 +156,8 @@ onMounted(async () => {
emailAddress.value = userData.email || '' emailAddress.value = userData.email || ''
emailOnFormCreated.value = userData.emailOnFormCreated ?? true emailOnFormCreated.value = userData.emailOnFormCreated ?? true
emailOnFormSubmitted.value = userData.emailOnFormSubmitted ?? true emailOnFormSubmitted.value = userData.emailOnFormSubmitted ?? true
emailOnFormUpdated.value = userData.emailOnFormUpdated ?? true
emailOnCommentAdded.value = userData.emailOnCommentAdded ?? true
} catch (error) { } catch (error) {
logger.error('Failed to load user email preferences:', error) logger.error('Failed to load user email preferences:', error)
} }
@@ -167,7 +173,9 @@ async function saveEmailPreferences() {
userStore.user.keycloakId, userStore.user.keycloakId,
emailAddress.value || null, emailAddress.value || null,
emailOnFormCreated.value, emailOnFormCreated.value,
emailOnFormSubmitted.value emailOnFormSubmitted.value,
emailOnFormUpdated.value,
emailOnCommentAdded.value
) )
toast.add({ toast.add({

View File

@@ -137,7 +137,10 @@
"title": "Benachrichtigungen", "title": "Benachrichtigungen",
"empty": "Keine Benachrichtigungen", "empty": "Keine Benachrichtigungen",
"unreadCount": "{count} ungelesen", "unreadCount": "{count} ungelesen",
"tooltip": "Benachrichtigungen" "tooltip": "Benachrichtigungen",
"markAllRead": "Alle als gelesen markieren",
"deleteAll": "Alle löschen",
"delete": "Benachrichtigung löschen"
}, },
"administration": { "administration": {
"title": "Administration", "title": "Administration",
@@ -237,6 +240,8 @@
"emailAddress": "E-Mail-Adresse", "emailAddress": "E-Mail-Adresse",
"onFormCreated": "Bei Erstellung eines Antrags", "onFormCreated": "Bei Erstellung eines Antrags",
"onFormSubmitted": "Bei Einreichung eines Antrags", "onFormSubmitted": "Bei Einreichung eines Antrags",
"onFormUpdated": "Wenn jemand meinen Antrag bearbeitet",
"onCommentAdded": "Wenn jemand meinen Antrag kommentiert",
"saved": "Einstellungen gespeichert" "saved": "Einstellungen gespeichert"
} }
} }

View File

@@ -137,7 +137,10 @@
"title": "Notifications", "title": "Notifications",
"empty": "No notifications", "empty": "No notifications",
"unreadCount": "{count} unread", "unreadCount": "{count} unread",
"tooltip": "Notifications" "tooltip": "Notifications",
"markAllRead": "Mark all as read",
"deleteAll": "Delete all",
"delete": "Delete notification"
}, },
"administration": { "administration": {
"title": "Administration", "title": "Administration",
@@ -237,6 +240,8 @@
"emailAddress": "Email Address", "emailAddress": "Email Address",
"onFormCreated": "When an application form is created", "onFormCreated": "When an application form is created",
"onFormSubmitted": "When an application form is submitted", "onFormSubmitted": "When an application form is submitted",
"onFormUpdated": "When someone edits my application form",
"onCommentAdded": "When someone comments on my application form",
"saved": "Settings saved" "saved": "Settings saved"
} }
} }

View File

@@ -1,28 +1,37 @@
import { defineStore } from 'pinia'
import type { NotificationDto } from '~~/.api-client' import type { NotificationDto } from '~~/.api-client'
import { useUserStore } from '~~/stores/useUserStore' import { useNotificationApi } from '~/composables/notification/useNotificationApi'
import { useLogger } from '~/composables/useLogger'
import { useUserStore } from './useUserStore'
export const useNotification = () => { export const useNotificationStore = defineStore('Notification', () => {
const logger = useLogger().withTag('notification') const logger = useLogger().withTag('notificationStore')
const userStore = useUserStore()
const { user } = useUserSession()
const { const {
getNotifications, getNotifications,
getUnreadNotifications, getUnreadNotifications,
getUnreadNotificationCount, getUnreadNotificationCount,
markAllNotificationsAsRead, markAllNotificationsAsRead,
markNotificationAsRead markNotificationAsRead,
clearAllNotifications,
deleteNotification
} = useNotificationApi() } = useNotificationApi()
const userStore = useUserStore() // State
const organizationId = computed(() => userStore.selectedOrganization?.id)
const { user } = useUserSession()
const userId = computed(() => user.value?.keycloakId)
const notifications = ref<NotificationDto[]>([]) const notifications = ref<NotificationDto[]>([])
const unreadNotifications = ref<NotificationDto[]>([]) const unreadNotifications = ref<NotificationDto[]>([])
const unreadCount = ref<number>(0) const unreadCount = ref<number>(0)
const isLoading = ref(false) const isLoading = ref(false)
const fetchNotifications = async (page: number = 0, size: number = 20) => { // Getters
const hasUnread = computed(() => unreadCount.value > 0)
const organizationId = computed(() => userStore.selectedOrganization?.id)
const userId = computed(() => user.value?.keycloakId)
// Actions
async function fetchNotifications(page: number = 0, size: number = 20) {
if (!organizationId.value) { if (!organizationId.value) {
logger.warn('No organization selected') logger.warn('No organization selected')
return return
@@ -40,7 +49,7 @@ export const useNotification = () => {
} }
} }
const fetchUnreadNotifications = async () => { async function fetchUnreadNotifications() {
if (!organizationId.value) { if (!organizationId.value) {
logger.warn('No organization selected') logger.warn('No organization selected')
return return
@@ -55,7 +64,7 @@ export const useNotification = () => {
} }
} }
const fetchUnreadCount = async () => { async function fetchUnreadCount() {
if (!userId.value || !organizationId.value) { if (!userId.value || !organizationId.value) {
logger.warn('No user or organization selected') logger.warn('No user or organization selected')
return return
@@ -70,7 +79,7 @@ export const useNotification = () => {
} }
} }
const markAllAsRead = async () => { async function markAllAsRead() {
if (!organizationId.value) { if (!organizationId.value) {
logger.warn('No organization selected') logger.warn('No organization selected')
return return
@@ -86,7 +95,7 @@ export const useNotification = () => {
} }
} }
const markAsRead = async (notificationId: string) => { async function markAsRead(notificationId: string) {
if (!organizationId.value) { if (!organizationId.value) {
logger.warn('No organization selected') logger.warn('No organization selected')
return return
@@ -109,7 +118,7 @@ export const useNotification = () => {
} }
} }
const handleNotificationClick = async (notification: NotificationDto) => { async function handleNotificationClick(notification: NotificationDto) {
if (!notification.isRead) { if (!notification.isRead) {
await markAsRead(notification.id) await markAsRead(notification.id)
} }
@@ -118,7 +127,7 @@ export const useNotification = () => {
} }
} }
const startPeriodicRefresh = (intervalMs: number = 30000) => { function startPeriodicRefresh(intervalMs: number = 30000) {
const interval = setInterval(() => { const interval = setInterval(() => {
void fetchUnreadCount() void fetchUnreadCount()
}, intervalMs) }, intervalMs)
@@ -130,17 +139,55 @@ export const useNotification = () => {
return interval return interval
} }
async function deleteAllNotifications() {
try {
await clearAllNotifications()
notifications.value = []
unreadNotifications.value = []
unreadCount.value = 0
} catch (error) {
logger.error('Failed to delete all notifications:', error)
throw error
}
}
async function deleteSingleNotification(notificationId: string) {
try {
// Check if notification was unread before deleting
const notification = notifications.value.find((n) => n.id === notificationId)
const wasUnread = notification && !notification.isRead
await deleteNotification(notificationId)
notifications.value = notifications.value.filter((n) => n.id !== notificationId)
unreadNotifications.value = unreadNotifications.value.filter((n) => n.id !== notificationId)
// Update unread count if the deleted notification was unread
if (wasUnread && unreadCount.value > 0) {
unreadCount.value--
}
} catch (error) {
logger.error('Failed to delete notification:', error)
throw error
}
}
return { return {
// State
notifications, notifications,
unreadNotifications, unreadNotifications,
unreadCount, unreadCount,
isLoading, isLoading,
// Getters
hasUnread,
// Actions
fetchNotifications, fetchNotifications,
fetchUnreadNotifications, fetchUnreadNotifications,
fetchUnreadCount, fetchUnreadCount,
markAllAsRead, markAllAsRead,
markAsRead, markAsRead,
handleNotificationClick, handleNotificationClick,
startPeriodicRefresh startPeriodicRefresh,
deleteAllNotifications,
deleteSingleNotification
} }
} })