feat(#23): Add email notifications
This commit is contained in:
@@ -539,6 +539,42 @@ paths:
|
|||||||
"503":
|
"503":
|
||||||
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServiceUnavailable"
|
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServiceUnavailable"
|
||||||
|
|
||||||
|
/users/{id}/email-preferences:
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
put:
|
||||||
|
summary: Update user email preferences
|
||||||
|
operationId: updateUserEmailPreferences
|
||||||
|
tags:
|
||||||
|
- user
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/UpdateEmailPreferencesDto"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Email preferences updated successfully
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/UserDto"
|
||||||
|
"400":
|
||||||
|
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/BadRequest"
|
||||||
|
"401":
|
||||||
|
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/Unauthorized"
|
||||||
|
"404":
|
||||||
|
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/NotFound"
|
||||||
|
"500":
|
||||||
|
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServerError"
|
||||||
|
"503":
|
||||||
|
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServiceUnavailable"
|
||||||
|
|
||||||
####### Comments #######
|
####### Comments #######
|
||||||
/comments/{id}:
|
/comments/{id}:
|
||||||
parameters:
|
parameters:
|
||||||
@@ -1426,6 +1462,26 @@ components:
|
|||||||
organizationId:
|
organizationId:
|
||||||
type: string
|
type: string
|
||||||
nullable: true
|
nullable: true
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
emailOnFormCreated:
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
emailOnFormSubmitted:
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
|
||||||
|
UpdateEmailPreferencesDto:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
emailOnFormCreated:
|
||||||
|
type: boolean
|
||||||
|
emailOnFormSubmitted:
|
||||||
|
type: boolean
|
||||||
|
|
||||||
UserStatus:
|
UserStatus:
|
||||||
type: string
|
type: string
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# Same file as .env in Synology docker directory
|
||||||
|
|
||||||
# Database Configuration
|
# Database Configuration
|
||||||
LEGALCONSENTHUB_POSTGRES_USER=legalconsenthub
|
LEGALCONSENTHUB_POSTGRES_USER=legalconsenthub
|
||||||
LEGALCONSENTHUB_POSTGRES_PASSWORD=legalconsenthub
|
LEGALCONSENTHUB_POSTGRES_PASSWORD=legalconsenthub
|
||||||
@@ -8,6 +10,8 @@ KEYCLOAK_POSTGRES_PASSWORD=keycloak
|
|||||||
KEYCLOAK_POSTGRES_DB=keycloak
|
KEYCLOAK_POSTGRES_DB=keycloak
|
||||||
KEYCLOAK_ISSUER_URL=http://keycloak.lugnas.de
|
KEYCLOAK_ISSUER_URL=http://keycloak.lugnas.de
|
||||||
|
|
||||||
|
MAIL_HOST=maildev
|
||||||
|
|
||||||
# Keycloak Configuration
|
# Keycloak Configuration
|
||||||
KEYCLOAK_ADMIN=admin
|
KEYCLOAK_ADMIN=admin
|
||||||
KEYCLOAK_ADMIN_PASSWORD=
|
KEYCLOAK_ADMIN_PASSWORD=
|
||||||
|
|||||||
@@ -61,3 +61,12 @@ services:
|
|||||||
retries: 5
|
retries: 5
|
||||||
start_period: 30s
|
start_period: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
|
|
||||||
|
maildev:
|
||||||
|
image: maildev/maildev:2.2.1
|
||||||
|
container_name: legalconsenthub-maildev
|
||||||
|
ports:
|
||||||
|
- "1080:1080"
|
||||||
|
- "1025:1025"
|
||||||
|
networks:
|
||||||
|
- legalconsenthub-net
|
||||||
|
|||||||
@@ -127,3 +127,12 @@ services:
|
|||||||
retries: 5
|
retries: 5
|
||||||
start_period: 30s
|
start_period: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
|
|
||||||
|
maildev:
|
||||||
|
image: maildev/maildev:2.2.1
|
||||||
|
container_name: legalconsenthub-maildev
|
||||||
|
ports:
|
||||||
|
- "1080:1080"
|
||||||
|
- "1025:1025"
|
||||||
|
networks:
|
||||||
|
- legalconsenthub-net
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ dependencies {
|
|||||||
implementation 'org.springframework.boot:spring-boot-starter-web'
|
implementation 'org.springframework.boot:spring-boot-starter-web'
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
|
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-actuator'
|
implementation 'org.springframework.boot:spring-boot-starter-actuator'
|
||||||
|
implementation 'org.springframework.boot:spring-boot-starter-mail'
|
||||||
implementation "com.openhtmltopdf:openhtmltopdf-core:$openHtmlVersion"
|
implementation "com.openhtmltopdf:openhtmltopdf-core:$openHtmlVersion"
|
||||||
implementation "com.openhtmltopdf:openhtmltopdf-pdfbox:$openHtmlVersion"
|
implementation "com.openhtmltopdf:openhtmltopdf-pdfbox:$openHtmlVersion"
|
||||||
implementation "com.openhtmltopdf:openhtmltopdf-java2d:$openHtmlVersion"
|
implementation "com.openhtmltopdf:openhtmltopdf-java2d:$openHtmlVersion"
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ package com.betriebsratkanzlei.legalconsenthub
|
|||||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
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
|
||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
@EnableJpaAuditing
|
@EnableJpaAuditing
|
||||||
|
@EnableAsync
|
||||||
class LegalconsenthubApplication
|
class LegalconsenthubApplication
|
||||||
|
|
||||||
fun main(args: Array<String>) {
|
fun main(args: Array<String>) {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package com.betriebsratkanzlei.legalconsenthub.application_form
|
package com.betriebsratkanzlei.legalconsenthub.application_form
|
||||||
|
|
||||||
import com.betriebsratkanzlei.legalconsenthub.application_form_version.ApplicationFormVersionService
|
import com.betriebsratkanzlei.legalconsenthub.application_form_version.ApplicationFormVersionService
|
||||||
|
import com.betriebsratkanzlei.legalconsenthub.email.ApplicationFormCreatedEvent
|
||||||
|
import com.betriebsratkanzlei.legalconsenthub.email.ApplicationFormSubmittedEvent
|
||||||
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
|
||||||
@@ -15,6 +17,7 @@ import com.betriebsratkanzlei.legalconsenthub_api.model.CreateApplicationFormDto
|
|||||||
import com.betriebsratkanzlei.legalconsenthub_api.model.CreateFormElementDto
|
import com.betriebsratkanzlei.legalconsenthub_api.model.CreateFormElementDto
|
||||||
import com.betriebsratkanzlei.legalconsenthub_api.model.CreateNotificationDto
|
import com.betriebsratkanzlei.legalconsenthub_api.model.CreateNotificationDto
|
||||||
import com.betriebsratkanzlei.legalconsenthub_api.model.NotificationType
|
import com.betriebsratkanzlei.legalconsenthub_api.model.NotificationType
|
||||||
|
import org.springframework.context.ApplicationEventPublisher
|
||||||
import org.springframework.data.domain.Page
|
import org.springframework.data.domain.Page
|
||||||
import org.springframework.data.domain.PageRequest
|
import org.springframework.data.domain.PageRequest
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
@@ -28,6 +31,7 @@ class ApplicationFormService(
|
|||||||
private val notificationService: NotificationService,
|
private val notificationService: NotificationService,
|
||||||
private val versionService: ApplicationFormVersionService,
|
private val versionService: ApplicationFormVersionService,
|
||||||
private val userService: UserService,
|
private val userService: UserService,
|
||||||
|
private val eventPublisher: ApplicationEventPublisher,
|
||||||
) {
|
) {
|
||||||
fun createApplicationForm(createApplicationFormDto: CreateApplicationFormDto): ApplicationForm {
|
fun createApplicationForm(createApplicationFormDto: CreateApplicationFormDto): ApplicationForm {
|
||||||
val applicationForm = applicationFormMapper.toApplicationForm(createApplicationFormDto)
|
val applicationForm = applicationFormMapper.toApplicationForm(createApplicationFormDto)
|
||||||
@@ -41,6 +45,15 @@ class ApplicationFormService(
|
|||||||
val currentUser = userService.getCurrentUser()
|
val currentUser = userService.getCurrentUser()
|
||||||
versionService.createVersion(savedApplicationForm, currentUser)
|
versionService.createVersion(savedApplicationForm, currentUser)
|
||||||
|
|
||||||
|
eventPublisher.publishEvent(
|
||||||
|
ApplicationFormCreatedEvent(
|
||||||
|
applicationFormId = savedApplicationForm.id!!,
|
||||||
|
organizationId = savedApplicationForm.organizationId,
|
||||||
|
creatorName = currentUser.name,
|
||||||
|
formName = savedApplicationForm.name,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
return savedApplicationForm
|
return savedApplicationForm
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +124,15 @@ class ApplicationFormService(
|
|||||||
|
|
||||||
createNotificationForOrganization(savedApplicationForm)
|
createNotificationForOrganization(savedApplicationForm)
|
||||||
|
|
||||||
|
eventPublisher.publishEvent(
|
||||||
|
ApplicationFormSubmittedEvent(
|
||||||
|
applicationFormId = savedApplicationForm.id!!,
|
||||||
|
organizationId = savedApplicationForm.organizationId,
|
||||||
|
creatorName = currentUser.name,
|
||||||
|
formName = savedApplicationForm.name,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
return savedApplicationForm
|
return savedApplicationForm
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.betriebsratkanzlei.legalconsenthub.email
|
||||||
|
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
data class ApplicationFormCreatedEvent(
|
||||||
|
val applicationFormId: UUID,
|
||||||
|
val organizationId: String?,
|
||||||
|
val creatorName: String,
|
||||||
|
val formName: String,
|
||||||
|
)
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.betriebsratkanzlei.legalconsenthub.email
|
||||||
|
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
data class ApplicationFormSubmittedEvent(
|
||||||
|
val applicationFormId: UUID,
|
||||||
|
val organizationId: String?,
|
||||||
|
val creatorName: String,
|
||||||
|
val formName: String,
|
||||||
|
)
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package com.betriebsratkanzlei.legalconsenthub.email
|
||||||
|
|
||||||
|
import com.betriebsratkanzlei.legalconsenthub.user.UserRepository
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import org.springframework.context.event.EventListener
|
||||||
|
import org.springframework.scheduling.annotation.Async
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
|
||||||
|
@Component
|
||||||
|
class EmailEventListener(
|
||||||
|
private val userRepository: UserRepository,
|
||||||
|
private val emailService: EmailService,
|
||||||
|
) {
|
||||||
|
private val logger = LoggerFactory.getLogger(EmailEventListener::class.java)
|
||||||
|
|
||||||
|
@Async
|
||||||
|
@EventListener
|
||||||
|
fun handleApplicationFormCreated(event: ApplicationFormCreatedEvent) {
|
||||||
|
logger.info("Processing ApplicationFormCreatedEvent for form: ${event.formName}")
|
||||||
|
|
||||||
|
val recipients =
|
||||||
|
userRepository
|
||||||
|
.findByOrganizationIdAndEmailOnFormCreatedTrue(event.organizationId)
|
||||||
|
.filter { !it.email.isNullOrBlank() }
|
||||||
|
|
||||||
|
logger.info("Found ${recipients.size} recipients for form created event")
|
||||||
|
|
||||||
|
recipients.forEach { user ->
|
||||||
|
val subject = "Neuer Mitbestimmungsantrag: ${event.formName}"
|
||||||
|
val body =
|
||||||
|
emailService.buildFormCreatedEmail(
|
||||||
|
formName = event.formName,
|
||||||
|
creatorName = event.creatorName,
|
||||||
|
applicationFormId = event.applicationFormId,
|
||||||
|
)
|
||||||
|
|
||||||
|
emailService.sendEmail(
|
||||||
|
to = user.email!!,
|
||||||
|
subject = subject,
|
||||||
|
body = body,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Async
|
||||||
|
@EventListener
|
||||||
|
fun handleApplicationFormSubmitted(event: ApplicationFormSubmittedEvent) {
|
||||||
|
logger.info("Processing ApplicationFormSubmittedEvent for form: ${event.formName}")
|
||||||
|
|
||||||
|
val recipients =
|
||||||
|
userRepository
|
||||||
|
.findByOrganizationIdAndEmailOnFormSubmittedTrue(event.organizationId)
|
||||||
|
.filter { !it.email.isNullOrBlank() }
|
||||||
|
|
||||||
|
logger.info("Found ${recipients.size} recipients for form submitted event")
|
||||||
|
|
||||||
|
recipients.forEach { user ->
|
||||||
|
val subject = "Mitbestimmungsantrag eingereicht: ${event.formName}"
|
||||||
|
val body =
|
||||||
|
emailService.buildFormSubmittedEmail(
|
||||||
|
formName = event.formName,
|
||||||
|
creatorName = event.creatorName,
|
||||||
|
applicationFormId = event.applicationFormId,
|
||||||
|
)
|
||||||
|
|
||||||
|
emailService.sendEmail(
|
||||||
|
to = user.email!!,
|
||||||
|
subject = subject,
|
||||||
|
body = body,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package com.betriebsratkanzlei.legalconsenthub.email
|
||||||
|
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import org.springframework.mail.MailException
|
||||||
|
import org.springframework.mail.javamail.JavaMailSender
|
||||||
|
import org.springframework.mail.javamail.MimeMessageHelper
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.thymeleaf.TemplateEngine
|
||||||
|
import org.thymeleaf.context.Context
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class EmailService(
|
||||||
|
private val mailSender: JavaMailSender,
|
||||||
|
private val templateEngine: TemplateEngine,
|
||||||
|
) {
|
||||||
|
private val logger = LoggerFactory.getLogger(EmailService::class.java)
|
||||||
|
|
||||||
|
fun sendEmail(
|
||||||
|
to: String,
|
||||||
|
subject: String,
|
||||||
|
body: String,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
val message = mailSender.createMimeMessage()
|
||||||
|
val helper = MimeMessageHelper(message, true, "UTF-8")
|
||||||
|
|
||||||
|
helper.setTo(to)
|
||||||
|
helper.setSubject(subject)
|
||||||
|
helper.setText(body, true)
|
||||||
|
helper.setFrom("noreply@legalconsenthub.com")
|
||||||
|
|
||||||
|
mailSender.send(message)
|
||||||
|
logger.info("Email sent successfully to: $to")
|
||||||
|
} catch (e: MailException) {
|
||||||
|
logger.error("Failed to send email to: $to", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun buildFormCreatedEmail(
|
||||||
|
formName: String,
|
||||||
|
creatorName: String,
|
||||||
|
applicationFormId: UUID,
|
||||||
|
): String {
|
||||||
|
val context = Context()
|
||||||
|
context.setVariable("formName", formName)
|
||||||
|
context.setVariable("creatorName", creatorName)
|
||||||
|
context.setVariable("applicationFormId", applicationFormId)
|
||||||
|
return templateEngine.process("email/form_created", context)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun buildFormSubmittedEmail(
|
||||||
|
formName: String,
|
||||||
|
creatorName: String,
|
||||||
|
applicationFormId: UUID,
|
||||||
|
): String {
|
||||||
|
val context = Context()
|
||||||
|
context.setVariable("formName", formName)
|
||||||
|
context.setVariable("creatorName", creatorName)
|
||||||
|
context.setVariable("applicationFormId", applicationFormId)
|
||||||
|
return templateEngine.process("email/form_submitted", context)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,7 @@ class JwtUserSyncFilter(
|
|||||||
val jwt: Jwt = auth.token
|
val jwt: Jwt = auth.token
|
||||||
val keycloakId = jwt.subject
|
val keycloakId = jwt.subject
|
||||||
val name = jwt.getClaimAsString("name")
|
val name = jwt.getClaimAsString("name")
|
||||||
|
val email = jwt.getClaimAsString("email")
|
||||||
|
|
||||||
// Extract organization information from JWT
|
// Extract organization information from JWT
|
||||||
val organizationClaim = jwt.getClaimAsMap("organization")
|
val organizationClaim = jwt.getClaimAsMap("organization")
|
||||||
@@ -46,7 +47,15 @@ class JwtUserSyncFilter(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val user = UserDto(keycloakId, name, organizationId)
|
val user =
|
||||||
|
UserDto(
|
||||||
|
keycloakId = keycloakId,
|
||||||
|
name = name,
|
||||||
|
organizationId = organizationId,
|
||||||
|
email = email,
|
||||||
|
emailOnFormCreated = null,
|
||||||
|
emailOnFormSubmitted = null,
|
||||||
|
)
|
||||||
|
|
||||||
if (keycloakId != null) {
|
if (keycloakId != null) {
|
||||||
userService.createUpdateUserFromJwt(user)
|
userService.createUpdateUserFromJwt(user)
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ class User(
|
|||||||
var name: String,
|
var name: String,
|
||||||
@Column(nullable = true)
|
@Column(nullable = true)
|
||||||
var organizationId: String? = null,
|
var organizationId: String? = null,
|
||||||
|
@Column(nullable = true)
|
||||||
|
var email: String? = null,
|
||||||
|
@Column(nullable = false)
|
||||||
|
var emailOnFormCreated: Boolean = true,
|
||||||
|
@Column(nullable = false)
|
||||||
|
var emailOnFormSubmitted: Boolean = true,
|
||||||
@CreatedDate
|
@CreatedDate
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
var createdAt: LocalDateTime? = null,
|
var createdAt: LocalDateTime? = null,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.betriebsratkanzlei.legalconsenthub.user
|
package com.betriebsratkanzlei.legalconsenthub.user
|
||||||
|
|
||||||
import com.betriebsratkanzlei.legalconsenthub_api.api.UserApi
|
import com.betriebsratkanzlei.legalconsenthub_api.api.UserApi
|
||||||
|
import com.betriebsratkanzlei.legalconsenthub_api.model.UpdateEmailPreferencesDto
|
||||||
import com.betriebsratkanzlei.legalconsenthub_api.model.UserDto
|
import com.betriebsratkanzlei.legalconsenthub_api.model.UserDto
|
||||||
import org.springframework.http.ResponseEntity
|
import org.springframework.http.ResponseEntity
|
||||||
import org.springframework.web.bind.annotation.RestController
|
import org.springframework.web.bind.annotation.RestController
|
||||||
@@ -19,4 +20,18 @@ class UserController(
|
|||||||
userService.deleteUser(id)
|
userService.deleteUser(id)
|
||||||
return ResponseEntity.noContent().build()
|
return ResponseEntity.noContent().build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun updateUserEmailPreferences(
|
||||||
|
id: String,
|
||||||
|
updateEmailPreferencesDto: UpdateEmailPreferencesDto,
|
||||||
|
): ResponseEntity<UserDto> {
|
||||||
|
val user =
|
||||||
|
userService.updateEmailPreferences(
|
||||||
|
userId = id,
|
||||||
|
email = updateEmailPreferencesDto.email,
|
||||||
|
emailOnFormCreated = updateEmailPreferencesDto.emailOnFormCreated ?: true,
|
||||||
|
emailOnFormSubmitted = updateEmailPreferencesDto.emailOnFormSubmitted ?: true,
|
||||||
|
)
|
||||||
|
return ResponseEntity.ok(userMapper.toUserDto(user))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ class UserMapper {
|
|||||||
keycloakId = user.keycloakId,
|
keycloakId = user.keycloakId,
|
||||||
name = user.name,
|
name = user.name,
|
||||||
organizationId = user.organizationId,
|
organizationId = user.organizationId,
|
||||||
|
email = user.email,
|
||||||
|
emailOnFormCreated = user.emailOnFormCreated,
|
||||||
|
emailOnFormSubmitted = user.emailOnFormSubmitted,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun toUser(userDto: UserDto): User {
|
fun toUser(userDto: UserDto): User {
|
||||||
@@ -18,6 +21,9 @@ class UserMapper {
|
|||||||
keycloakId = userDto.keycloakId,
|
keycloakId = userDto.keycloakId,
|
||||||
name = userDto.name,
|
name = userDto.name,
|
||||||
organizationId = userDto.organizationId,
|
organizationId = userDto.organizationId,
|
||||||
|
email = userDto.email,
|
||||||
|
emailOnFormCreated = userDto.emailOnFormCreated ?: true,
|
||||||
|
emailOnFormSubmitted = userDto.emailOnFormSubmitted ?: true,
|
||||||
)
|
)
|
||||||
|
|
||||||
return user
|
return user
|
||||||
|
|||||||
@@ -4,4 +4,8 @@ import org.springframework.data.jpa.repository.JpaRepository
|
|||||||
import org.springframework.stereotype.Repository
|
import org.springframework.stereotype.Repository
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
interface UserRepository : JpaRepository<User, String>
|
interface UserRepository : JpaRepository<User, String> {
|
||||||
|
fun findByOrganizationIdAndEmailOnFormCreatedTrue(organizationId: String?): List<User>
|
||||||
|
|
||||||
|
fun findByOrganizationIdAndEmailOnFormSubmittedTrue(organizationId: String?): List<User>
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,9 +17,7 @@ class UserService(
|
|||||||
val principal = SecurityContextHolder.getContext().authentication.principal as CustomJwtTokenPrincipal
|
val principal = SecurityContextHolder.getContext().authentication.principal as CustomJwtTokenPrincipal
|
||||||
val userId = principal.id ?: throw IllegalStateException("User ID not found")
|
val userId = principal.id ?: throw IllegalStateException("User ID not found")
|
||||||
|
|
||||||
return userRepository
|
return userRepository.findById(userId).orElseThrow { UserNotFoundException(userId) }
|
||||||
.findById(userId)
|
|
||||||
.orElseThrow { UserNotFoundException(userId) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -29,11 +27,7 @@ class UserService(
|
|||||||
if (existingUser.isEmpty) {
|
if (existingUser.isEmpty) {
|
||||||
return createUser(userDto)
|
return createUser(userDto)
|
||||||
} else {
|
} else {
|
||||||
val user = existingUser.get()
|
return updateUser(userDto)
|
||||||
if (user.organizationId == null && userDto.organizationId != null) {
|
|
||||||
user.organizationId = userDto.organizationId
|
|
||||||
}
|
|
||||||
return updateUser(userMapper.toUserDto(user))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,33 +41,49 @@ class UserService(
|
|||||||
keycloakId = userDto.keycloakId,
|
keycloakId = userDto.keycloakId,
|
||||||
name = userDto.name,
|
name = userDto.name,
|
||||||
organizationId = userDto.organizationId,
|
organizationId = userDto.organizationId,
|
||||||
|
email = userDto.email,
|
||||||
)
|
)
|
||||||
|
|
||||||
return userRepository.save(user)
|
return userRepository.save(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getUserById(userId: String): User =
|
fun getUserById(userId: String): User =
|
||||||
userRepository
|
userRepository.findById(userId).orElseThrow { UserNotFoundException(userId) }
|
||||||
.findById(userId)
|
|
||||||
.orElseThrow { UserNotFoundException(userId) }
|
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun updateUser(userDto: UserDto): User {
|
fun updateUser(userDto: UserDto): User {
|
||||||
val user =
|
val user = userRepository.findById(userDto.keycloakId).orElseThrow { UserNotFoundException(userDto.keycloakId) }
|
||||||
userRepository
|
|
||||||
.findById(userDto.keycloakId)
|
|
||||||
.orElseThrow { UserNotFoundException(userDto.keycloakId) }
|
|
||||||
|
|
||||||
user.name = userDto.name
|
user.name = userDto.name
|
||||||
// Only update organization if it's not already set
|
|
||||||
if (user.organizationId == null && userDto.organizationId != null) {
|
if (user.organizationId == null && userDto.organizationId != null) {
|
||||||
user.organizationId = userDto.organizationId
|
user.organizationId = userDto.organizationId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (userDto.email != null && user.email != userDto.email) {
|
||||||
|
user.email = userDto.email
|
||||||
|
}
|
||||||
|
|
||||||
return userRepository.save(user)
|
return userRepository.save(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteUser(userId: String) {
|
fun deleteUser(userId: String) {
|
||||||
userRepository.deleteById(userId)
|
userRepository.deleteById(userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun updateEmailPreferences(
|
||||||
|
userId: String,
|
||||||
|
email: String?,
|
||||||
|
emailOnFormCreated: Boolean,
|
||||||
|
emailOnFormSubmitted: Boolean,
|
||||||
|
): User {
|
||||||
|
val user = userRepository.findById(userId).orElseThrow { UserNotFoundException(userId) }
|
||||||
|
|
||||||
|
user.email = email
|
||||||
|
user.emailOnFormCreated = emailOnFormCreated
|
||||||
|
user.emailOnFormSubmitted = emailOnFormSubmitted
|
||||||
|
|
||||||
|
return userRepository.save(user)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,3 +38,21 @@ spring:
|
|||||||
jwt:
|
jwt:
|
||||||
issuer-uri: ${SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI}
|
issuer-uri: ${SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI}
|
||||||
jwk-set-uri: ${SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI}
|
jwk-set-uri: ${SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI}
|
||||||
|
|
||||||
|
mail:
|
||||||
|
host: ${MAIL_HOST:0.0.0.0}
|
||||||
|
port: ${MAIL_PORT:1025}
|
||||||
|
username: ${MAIL_USERNAME:}
|
||||||
|
password: ${MAIL_PASSWORD:}
|
||||||
|
properties:
|
||||||
|
mail:
|
||||||
|
smtp:
|
||||||
|
auth: false
|
||||||
|
starttls:
|
||||||
|
enable: false
|
||||||
|
required: false
|
||||||
|
ssl:
|
||||||
|
enable: false
|
||||||
|
connectiontimeout: 5000
|
||||||
|
timeout: 5000
|
||||||
|
writetimeout: 5000
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<!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: #4F46E5; 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: #4F46E5; 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 Mitbestimmungsantrag erstellt</h2>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<p>Hallo,</p>
|
||||||
|
<p>Ein neuer Mitbestimmungsantrag wurde erstellt:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Antrag:</strong> <span th:text="${formName}"></span></li>
|
||||||
|
<li><strong>Erstellt von:</strong> <span th:text="${creatorName}"></span></li>
|
||||||
|
</ul>
|
||||||
|
<p>Klicken Sie auf den folgenden Link, um den Antrag anzusehen:</p>
|
||||||
|
<a th:href="@{http://localhost:3001/application-forms/{id}/0(id=${applicationFormId})}" class="button">Antrag 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,35 @@
|
|||||||
|
<!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: #059669; 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: #059669; 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 eingereicht</h2>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<p>Hallo,</p>
|
||||||
|
<p>Ein Mitbestimmungsantrag wurde zur Prüfung eingereicht:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Antrag:</strong> <span th:text="${formName}"></span></li>
|
||||||
|
<li><strong>Eingereicht von:</strong> <span th:text="${creatorName}"></span></li>
|
||||||
|
</ul>
|
||||||
|
<p>Klicken Sie auf den folgenden Link, um den Antrag zu prüfen:</p>
|
||||||
|
<a th:href="@{http://localhost:3001/application-forms/{id}/0(id=${applicationFormId})}" class="button">Antrag prüfen</a>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<p>Diese E-Mail wurde automatisch von Legal Consent Hub gesendet.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
@@ -5,3 +5,5 @@ export { useApplicationFormVersionApi } from './applicationFormVersion/useApplic
|
|||||||
export { useApplicationFormNavigation } from './useApplicationFormNavigation'
|
export { useApplicationFormNavigation } from './useApplicationFormNavigation'
|
||||||
export { useNotification } from './notification/useNotification'
|
export { useNotification } from './notification/useNotification'
|
||||||
export { useNotificationApi } from './notification/useNotificationApi'
|
export { useNotificationApi } from './notification/useNotificationApi'
|
||||||
|
export { useUser } from './user/useUser'
|
||||||
|
export { useUserApi } from './user/useUserApi'
|
||||||
|
|||||||
26
legalconsenthub/app/composables/user/useUser.ts
Normal file
26
legalconsenthub/app/composables/user/useUser.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import type { UpdateEmailPreferencesDto, UserDto } from '~~/.api-client'
|
||||||
|
import { useUserApi } from '~/composables'
|
||||||
|
|
||||||
|
export function useUser() {
|
||||||
|
const userApi = useUserApi()
|
||||||
|
|
||||||
|
async function getUserById(userId: string): Promise<UserDto> {
|
||||||
|
return await userApi.getUserById(userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateEmailPreferences(
|
||||||
|
userId: string,
|
||||||
|
email: string | null,
|
||||||
|
emailOnFormCreated: boolean,
|
||||||
|
emailOnFormSubmitted: boolean
|
||||||
|
): Promise<UserDto> {
|
||||||
|
const updateDto: UpdateEmailPreferencesDto = { email, emailOnFormCreated, emailOnFormSubmitted }
|
||||||
|
|
||||||
|
return await userApi.updateEmailPreferences(userId, updateDto)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
getUserById,
|
||||||
|
updateEmailPreferences
|
||||||
|
}
|
||||||
|
}
|
||||||
42
legalconsenthub/app/composables/user/useUserApi.ts
Normal file
42
legalconsenthub/app/composables/user/useUserApi.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { UserApi, Configuration, type UserDto, type UpdateEmailPreferencesDto } from '~~/.api-client'
|
||||||
|
import { cleanDoubleSlashes, withoutTrailingSlash } from 'ufo'
|
||||||
|
import { wrappedFetchWrap } from '~/utils/wrappedFetch'
|
||||||
|
|
||||||
|
export function useUserApi() {
|
||||||
|
const appBaseUrl = useRuntimeConfig().app.baseURL
|
||||||
|
const { serverApiBasePath, clientProxyBasePath } = useRuntimeConfig().public
|
||||||
|
|
||||||
|
const basePath = withoutTrailingSlash(
|
||||||
|
cleanDoubleSlashes(
|
||||||
|
import.meta.client
|
||||||
|
? appBaseUrl + clientProxyBasePath
|
||||||
|
: useRequestURL().origin + clientProxyBasePath + serverApiBasePath
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
const userApiClient = new UserApi(
|
||||||
|
new Configuration({ basePath, fetchApi: wrappedFetchWrap(useRequestFetch()) })
|
||||||
|
)
|
||||||
|
|
||||||
|
async function getUserById(id: string): Promise<UserDto> {
|
||||||
|
return userApiClient.getUserById({ id })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateEmailPreferences(
|
||||||
|
id: string,
|
||||||
|
updateEmailPreferencesDto: UpdateEmailPreferencesDto
|
||||||
|
): Promise<UserDto> {
|
||||||
|
return userApiClient.updateUserEmailPreferences({ id, updateEmailPreferencesDto })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteUser(id: string): Promise<void> {
|
||||||
|
return userApiClient.deleteUser({ id })
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
getUserById,
|
||||||
|
updateEmailPreferences,
|
||||||
|
deleteUser
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -72,6 +72,33 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</UCard>
|
</UCard>
|
||||||
|
|
||||||
|
<!-- Email Notifications Section -->
|
||||||
|
<UCard>
|
||||||
|
<template #header>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold text-highlighted">{{ $t('settings.email.title') }}</h3>
|
||||||
|
<p class="text-sm text-muted mt-1">{{ $t('settings.email.description') }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<UInput
|
||||||
|
v-model="emailAddress"
|
||||||
|
:label="$t('settings.email.emailAddress')"
|
||||||
|
type="email"
|
||||||
|
placeholder="user@example.com"
|
||||||
|
class="w-full max-w-md"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<UCheckbox v-model="emailOnFormCreated" :label="$t('settings.email.onFormCreated')" />
|
||||||
|
<UCheckbox v-model="emailOnFormSubmitted" :label="$t('settings.email.onFormSubmitted')" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UButton :label="$t('common.save')" color="primary" :loading="isSaving" @click="saveEmailPreferences" />
|
||||||
|
</div>
|
||||||
|
</UCard>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</UDashboardPanel>
|
</UDashboardPanel>
|
||||||
@@ -79,6 +106,8 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { de, en } from '@nuxt/ui/locale'
|
import { de, en } from '@nuxt/ui/locale'
|
||||||
|
import { useUserStore } from '~~/stores/useUserStore'
|
||||||
|
import { useUser } from '~/composables'
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: 'default'
|
layout: 'default'
|
||||||
@@ -87,6 +116,9 @@ definePageMeta({
|
|||||||
const { t: $t, locale, setLocale } = useI18n()
|
const { t: $t, locale, setLocale } = useI18n()
|
||||||
const colorMode = useColorMode()
|
const colorMode = useColorMode()
|
||||||
const appConfig = useAppConfig()
|
const appConfig = useAppConfig()
|
||||||
|
const toast = useToast()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const { getUserById, updateEmailPreferences } = useUser()
|
||||||
|
|
||||||
const colors = [
|
const colors = [
|
||||||
'red',
|
'red',
|
||||||
@@ -107,6 +139,50 @@ const colors = [
|
|||||||
'pink'
|
'pink'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const emailAddress = ref('')
|
||||||
|
const emailOnFormCreated = ref(true)
|
||||||
|
const emailOnFormSubmitted = ref(true)
|
||||||
|
const isSaving = ref(false)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (userStore.user) {
|
||||||
|
try {
|
||||||
|
const userData = await getUserById(userStore.user.keycloakId)
|
||||||
|
emailAddress.value = userData.email || ''
|
||||||
|
emailOnFormCreated.value = userData.emailOnFormCreated ?? true
|
||||||
|
emailOnFormSubmitted.value = userData.emailOnFormSubmitted ?? true
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load user email preferences:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function saveEmailPreferences() {
|
||||||
|
if (!userStore.user) return
|
||||||
|
|
||||||
|
isSaving.value = true
|
||||||
|
try {
|
||||||
|
await updateEmailPreferences(
|
||||||
|
userStore.user.keycloakId,
|
||||||
|
emailAddress.value || null,
|
||||||
|
emailOnFormCreated.value,
|
||||||
|
emailOnFormSubmitted.value
|
||||||
|
)
|
||||||
|
|
||||||
|
toast.add({
|
||||||
|
title: $t('settings.email.saved'),
|
||||||
|
color: 'success'
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
toast.add({
|
||||||
|
title: $t('common.error'),
|
||||||
|
color: 'error'
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
isSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleLocaleChange(newLocale: string | undefined) {
|
function handleLocaleChange(newLocale: string | undefined) {
|
||||||
if (newLocale) {
|
if (newLocale) {
|
||||||
setLocale(newLocale as 'de' | 'en')
|
setLocale(newLocale as 'de' | 'en')
|
||||||
|
|||||||
@@ -170,5 +170,31 @@
|
|||||||
"employer": "Arbeitgeber",
|
"employer": "Arbeitgeber",
|
||||||
"worksCouncilMember": "Betriebsratsmitglied",
|
"worksCouncilMember": "Betriebsratsmitglied",
|
||||||
"worksCouncilChair": "Betriebsratsvorsitzender"
|
"worksCouncilChair": "Betriebsratsvorsitzender"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "Einstellungen",
|
||||||
|
"language": {
|
||||||
|
"title": "Sprache",
|
||||||
|
"description": "Wählen Sie Ihre bevorzugte Sprache"
|
||||||
|
},
|
||||||
|
"appearance": {
|
||||||
|
"title": "Erscheinungsbild",
|
||||||
|
"description": "Wählen Sie Ihr bevorzugtes Farbschema",
|
||||||
|
"light": "Hell",
|
||||||
|
"dark": "Dunkel"
|
||||||
|
},
|
||||||
|
"theme": {
|
||||||
|
"title": "Designfarben",
|
||||||
|
"description": "Passen Sie die Primärfarbe an",
|
||||||
|
"primary": "Primärfarbe"
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"title": "E-Mail-Benachrichtigungen",
|
||||||
|
"description": "Verwalten Sie Ihre E-Mail-Benachrichtigungseinstellungen",
|
||||||
|
"emailAddress": "E-Mail-Adresse",
|
||||||
|
"onFormCreated": "Bei Erstellung eines Antrags",
|
||||||
|
"onFormSubmitted": "Bei Einreichung eines Antrags",
|
||||||
|
"saved": "Einstellungen gespeichert"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -170,5 +170,31 @@
|
|||||||
"employer": "Employer",
|
"employer": "Employer",
|
||||||
"worksCouncilMember": "Works Council Member",
|
"worksCouncilMember": "Works Council Member",
|
||||||
"worksCouncilChair": "Works Council Chair"
|
"worksCouncilChair": "Works Council Chair"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "Settings",
|
||||||
|
"language": {
|
||||||
|
"title": "Language",
|
||||||
|
"description": "Select your preferred language"
|
||||||
|
},
|
||||||
|
"appearance": {
|
||||||
|
"title": "Appearance",
|
||||||
|
"description": "Choose your preferred color scheme",
|
||||||
|
"light": "Light",
|
||||||
|
"dark": "Dark"
|
||||||
|
},
|
||||||
|
"theme": {
|
||||||
|
"title": "Theme Colors",
|
||||||
|
"description": "Customize the primary color",
|
||||||
|
"primary": "Primary Color"
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"title": "Email Notifications",
|
||||||
|
"description": "Manage your email notification preferences",
|
||||||
|
"emailAddress": "Email Address",
|
||||||
|
"onFormCreated": "When an application form is created",
|
||||||
|
"onFormSubmitted": "When an application form is submitted",
|
||||||
|
"saved": "Settings saved"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user