feat(#36): Notification rework with single and all comments mark as read
This commit is contained in:
@@ -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}:
|
||||||
delete:
|
|
||||||
summary: Clear all notifications for the current user
|
|
||||||
operationId: clearAllNotifications
|
|
||||||
tags:
|
|
||||||
- notification
|
|
||||||
parameters:
|
parameters:
|
||||||
- in: query
|
- name: id
|
||||||
name: organizationId
|
in: path
|
||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
description: Organization ID to clear notifications for
|
format: uuid
|
||||||
|
delete:
|
||||||
|
summary: Delete a specific notification
|
||||||
|
operationId: deleteNotification
|
||||||
|
tags:
|
||||||
|
- notification
|
||||||
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:
|
||||||
|
|||||||
@@ -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>) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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?
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -1,16 +1,40 @@
|
|||||||
<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"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<NuxtLink
|
||||||
:to="notification.clickTarget"
|
: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"
|
class="flex items-center gap-3 flex-1"
|
||||||
@click="onNotificationClick(notification)"
|
@click="onNotificationClick(notification)"
|
||||||
>
|
>
|
||||||
<UChip
|
<UChip
|
||||||
@@ -56,6 +80,19 @@
|
|||||||
</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>
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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>(
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
Reference in New Issue
Block a user