feat(fullstack): Add notifications, user is now an entity, add testcontainers, rework custom permissions, get user from JWT in endpoints

This commit is contained in:
2025-08-09 10:09:00 +02:00
parent a5eae07eaf
commit 7e55a336f2
44 changed files with 1571 additions and 139 deletions

View File

@@ -1,6 +1,6 @@
<component name="ProjectRunConfigurationManager"> <component name="ProjectRunConfigurationManager">
<configuration default="false" name="local-server-backend-h2" type="SpringBootApplicationConfigurationType" factoryName="Spring Boot"> <configuration default="false" name="local-server-backend-h2" type="SpringBootApplicationConfigurationType" factoryName="Spring Boot">
<option name="ACTIVE_PROFILES" value="local-h2" /> <option name="ACTIVE_PROFILES" value="h2" />
<option name="ALTERNATIVE_JRE_PATH" value="ms-21" /> <option name="ALTERNATIVE_JRE_PATH" value="ms-21" />
<option name="ALTERNATIVE_JRE_PATH_ENABLED" value="true" /> <option name="ALTERNATIVE_JRE_PATH_ENABLED" value="true" />
<module name="com.betriebsratkanzlei.legalconsenthub.main" /> <module name="com.betriebsratkanzlei.legalconsenthub.main" />

View File

@@ -339,6 +339,34 @@ paths:
$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 ####### ####### Users #######
/users:
post:
summary: Create a new user
operationId: createUser
tags:
- user
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateUserDto"
responses:
"201":
description: User successfully created
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"
"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"
/users/{id}: /users/{id}:
parameters: parameters:
- name: id - name: id
@@ -346,7 +374,6 @@ paths:
required: true required: true
schema: schema:
type: string type: string
format: uuid
get: get:
summary: Get a specific user summary: Get a specific user
operationId: getUserById operationId: getUserById
@@ -602,6 +629,142 @@ 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"
####### Notifications #######
/notifications:
get:
summary: Get notifications for the current user
operationId: getNotifications
tags:
- notification
parameters:
- in: query
name: page
schema:
type: integer
default: 0
description: Page number
- in: query
name: size
schema:
type: integer
default: 20
description: Page size
responses:
"200":
description: Paged list of notifications
content:
application/json:
schema:
$ref: "#/components/schemas/PagedNotificationDto"
"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"
post:
summary: Create a new notification
operationId: createNotification
tags:
- notification
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateNotificationDto"
responses:
"201":
description: Successfully created notification
content:
application/json:
schema:
$ref: "#/components/schemas/NotificationDto"
"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"
"500":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServerError"
/notifications/unread:
get:
summary: Get unread notifications for the current user
operationId: getUnreadNotifications
tags:
- notification
responses:
"200":
description: List of unread notifications
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/NotificationDto"
"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/count:
get:
summary: Get count of unread notifications for the current user
operationId: getUnreadNotificationCount
tags:
- notification
responses:
"200":
description: Count of unread notifications
content:
application/json:
schema:
type: integer
format: int64
"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/mark-all-read:
put:
summary: Mark all notifications as read for the current user
operationId: markAllNotificationsAsRead
tags:
- notification
responses:
"204":
description: All notifications marked as read
"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/{id}/mark-read:
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
put:
summary: Mark a specific notification as read
operationId: markNotificationAsRead
tags:
- notification
responses:
"200":
description: Notification marked as read
content:
application/json:
schema:
$ref: "#/components/schemas/NotificationDto"
"404":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/NotFound"
"401":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/Unauthorized"
"500":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServerError"
####### Files ####### ####### Files #######
/files: /files:
get: get:
@@ -751,7 +914,6 @@ components:
type: string type: string
status: status:
$ref: "#/components/schemas/ApplicationFormStatus" $ref: "#/components/schemas/ApplicationFormStatus"
default: DRAFT
PagedApplicationFormDto: PagedApplicationFormDto:
type: object type: object
@@ -886,11 +1048,51 @@ components:
required: required:
- id - id
- name - name
- status
properties: properties:
id: id:
type: string type: string
name: name:
type: string type: string
status:
$ref: "#/components/schemas/UserStatus"
role:
$ref: "#/components/schemas/UserRole"
CreateUserDto:
type: object
required:
- id
- name
- status
- role
properties:
id:
type: string
name:
type: string
status:
$ref: "#/components/schemas/UserStatus"
role:
$ref: "#/components/schemas/UserRole"
UserStatus:
type: string
enum:
- INVITED
- ACTIVE
- BLOCKED
- SUSPENDED_SUBSCRIPTION
UserRole:
type: string
description: "User's role in the organization"
enum:
- owner
- admin
- employer
- works_council_member
- employee
####### CommentDto ####### ####### CommentDto #######
CommentDto: CommentDto:
@@ -968,6 +1170,85 @@ components:
name: name:
type: string type: string
####### Notification #######
NotificationDto:
type: object
required:
- id
- title
- message
- clickTarget
- isRead
- targetGroup
- type
- createdAt
properties:
id:
type: string
format: uuid
title:
type: string
message:
type: string
clickTarget:
type: string
isRead:
type: boolean
recipient:
nullable: true
allOf:
- $ref: "#/components/schemas/UserDto"
targetGroup:
type: string
type:
$ref: "#/components/schemas/NotificationType"
createdAt:
type: string
format: date-time
CreateNotificationDto:
type: object
required:
- title
- message
- clickTarget
- targetGroup
- type
properties:
title:
type: string
message:
type: string
clickTarget:
type: string
recipient:
nullable: true
allOf:
- $ref: "#/components/schemas/UserDto"
targetGroup:
type: string
type:
$ref: "#/components/schemas/NotificationType"
PagedNotificationDto:
type: object
allOf:
- $ref: "#/components/schemas/Page"
required:
- content
properties:
content:
type: array
items:
$ref: "#/components/schemas/NotificationDto"
NotificationType:
type: string
enum:
- INFO
- WARNING
- ERROR
####### FileDto ####### ####### FileDto #######
FileDto: FileDto:
type: object type: object
@@ -1004,24 +1285,6 @@ components:
type: string type: string
format: binary format: binary
####### Notification #######
NotificationDto:
type: object
required:
- id
- notificationType
properties:
id:
type: string
format: uuid
notificationType:
$ref: "#/components/schemas/NotificationType"
NotificationType:
type: string
enum:
- EMAIL
####### Miscellaneous ####### ####### Miscellaneous #######
ProcessingPurpose: ProcessingPurpose:
type: string type: string

View File

@@ -24,6 +24,12 @@ ext {
openHtmlVersion = '1.0.10' openHtmlVersion = '1.0.10'
} }
//dependencyManagement {
// imports {
// mavenBom 'eu.europa.ec.joinup.sd-dss:dss-bom:6.2'
// }
//}
dependencies { dependencies {
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin' implementation 'com.fasterxml.jackson.module:jackson-module-kotlin'
implementation 'org.jetbrains.kotlin:kotlin-reflect' implementation 'org.jetbrains.kotlin:kotlin-reflect'
@@ -42,8 +48,13 @@ dependencies {
implementation "com.openhtmltopdf:openhtmltopdf-slf4j:$openHtmlVersion" implementation "com.openhtmltopdf:openhtmltopdf-slf4j:$openHtmlVersion"
implementation "com.openhtmltopdf:openhtmltopdf-svg-support:$openHtmlVersion" implementation "com.openhtmltopdf:openhtmltopdf-svg-support:$openHtmlVersion"
runtimeOnly 'com.h2database:h2' runtimeOnly 'com.h2database:h2'
runtimeOnly 'org.postgresql:postgresql'
implementation 'org.springframework.boot:spring-boot-testcontainers'
implementation 'org.testcontainers:postgresql'
testImplementation 'org.jetbrains.kotlin:kotlin-test-junit5' testImplementation 'org.jetbrains.kotlin:kotlin-test-junit5'
testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.boot:spring-boot-starter-test'
// implementation 'eu.europa.ec.joinup.sd-dss:dss-validation'
// implementation 'eu.europa.ec.joinup.sd-dss:dss-pades'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher' testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
} }

View File

@@ -3,8 +3,8 @@ package com.betriebsratkanzlei.legalconsenthub.application_form
import com.betriebsratkanzlei.legalconsenthub.form_element.FormElementSection import com.betriebsratkanzlei.legalconsenthub.form_element.FormElementSection
import com.betriebsratkanzlei.legalconsenthub.user.User import com.betriebsratkanzlei.legalconsenthub.user.User
import com.betriebsratkanzlei.legalconsenthub_api.model.ApplicationFormStatus import com.betriebsratkanzlei.legalconsenthub_api.model.ApplicationFormStatus
import jakarta.persistence.AttributeOverride import jakarta.persistence.JoinColumn
import jakarta.persistence.AttributeOverrides import jakarta.persistence.ManyToOne
import jakarta.persistence.CascadeType import jakarta.persistence.CascadeType
import jakarta.persistence.Column import jakarta.persistence.Column
import jakarta.persistence.Entity import jakarta.persistence.Entity
@@ -14,7 +14,7 @@ import jakarta.persistence.EnumType
import jakarta.persistence.GeneratedValue import jakarta.persistence.GeneratedValue
import jakarta.persistence.Id import jakarta.persistence.Id
import jakarta.persistence.OneToMany import jakarta.persistence.OneToMany
import jakarta.persistence.Embedded
import org.springframework.data.annotation.CreatedDate import org.springframework.data.annotation.CreatedDate
import org.springframework.data.annotation.LastModifiedDate import org.springframework.data.annotation.LastModifiedDate
import org.springframework.data.jpa.domain.support.AuditingEntityListener import org.springframework.data.jpa.domain.support.AuditingEntityListener
@@ -43,18 +43,12 @@ class ApplicationForm(
@Column(nullable = false) @Column(nullable = false)
var status: ApplicationFormStatus = ApplicationFormStatus.DRAFT, var status: ApplicationFormStatus = ApplicationFormStatus.DRAFT,
@Embedded @ManyToOne
@AttributeOverrides( @JoinColumn(name = "created_by_id", nullable = false)
AttributeOverride(name = "id", column = Column(name = "created_by_id", nullable = false)),
AttributeOverride(name = "name", column = Column(name = "created_by_name", nullable = false))
)
var createdBy: User, var createdBy: User,
@Embedded @ManyToOne
@AttributeOverrides( @JoinColumn(name = "last_modified_by_id", nullable = false)
AttributeOverride(name = "id", column = Column(name = "last_modified_by_id", nullable = false)),
AttributeOverride(name = "name", column = Column(name = "last_modified_by_name", nullable = false))
)
var lastModifiedBy: User, var lastModifiedBy: User,
@CreatedDate @CreatedDate

View File

@@ -1,17 +1,20 @@
package com.betriebsratkanzlei.legalconsenthub.application_form package com.betriebsratkanzlei.legalconsenthub.application_form
import com.betriebsratkanzlei.legalconsenthub.form_element.FormElementSectionMapper import com.betriebsratkanzlei.legalconsenthub.form_element.FormElementSectionMapper
import com.betriebsratkanzlei.legalconsenthub.security.CustomJwtTokenPrincipal
import com.betriebsratkanzlei.legalconsenthub.user.User import com.betriebsratkanzlei.legalconsenthub.user.User
import com.betriebsratkanzlei.legalconsenthub.user.UserMapper import com.betriebsratkanzlei.legalconsenthub.user.UserMapper
import com.betriebsratkanzlei.legalconsenthub.user.UserService
import com.betriebsratkanzlei.legalconsenthub_api.model.ApplicationFormDto import com.betriebsratkanzlei.legalconsenthub_api.model.ApplicationFormDto
import com.betriebsratkanzlei.legalconsenthub_api.model.CreateApplicationFormDto import com.betriebsratkanzlei.legalconsenthub_api.model.CreateApplicationFormDto
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import java.time.LocalDateTime import java.time.LocalDateTime
@Component @Component
class ApplicationFormMapper(private val formElementSectionMapper: FormElementSectionMapper, private val userMapper: UserMapper) { class ApplicationFormMapper(
private val formElementSectionMapper: FormElementSectionMapper,
private val userMapper: UserMapper,
private val userService: UserService
) {
fun toApplicationFormDto(applicationForm: ApplicationForm): ApplicationFormDto { fun toApplicationFormDto(applicationForm: ApplicationForm): ApplicationFormDto {
return ApplicationFormDto( return ApplicationFormDto(
id = applicationForm.id ?: throw IllegalStateException("ApplicationForm ID must not be null!"), id = applicationForm.id ?: throw IllegalStateException("ApplicationForm ID must not be null!"),
@@ -43,18 +46,15 @@ class ApplicationFormMapper(private val formElementSectionMapper: FormElementSec
} }
fun toApplicationForm(createApplicationFormDto: CreateApplicationFormDto): ApplicationForm { fun toApplicationForm(createApplicationFormDto: CreateApplicationFormDto): ApplicationForm {
// TODO: Move this in upper layer val currentUser = userService.getCurrentUser()
val principal = SecurityContextHolder.getContext().authentication.principal as CustomJwtTokenPrincipal
val createdBy = User(principal.name ?: "UNKNOWN USER", principal.id ?: "")
val lastModifiedBy = User(principal.name ?: "UNKNOWN USER", principal.id ?: "")
val applicationForm = ApplicationForm( val applicationForm = ApplicationForm(
name = createApplicationFormDto.name, name = createApplicationFormDto.name,
isTemplate = createApplicationFormDto.isTemplate, isTemplate = createApplicationFormDto.isTemplate,
organizationId = createApplicationFormDto.organizationId ?: "", organizationId = createApplicationFormDto.organizationId ?: "",
status = createApplicationFormDto.status ?: com.betriebsratkanzlei.legalconsenthub_api.model.ApplicationFormStatus.DRAFT, status = createApplicationFormDto.status ?: com.betriebsratkanzlei.legalconsenthub_api.model.ApplicationFormStatus.DRAFT,
createdBy = createdBy, createdBy = currentUser,
lastModifiedBy = lastModifiedBy, lastModifiedBy = currentUser,
) )
applicationForm.formElementSections = createApplicationFormDto.formElementSections applicationForm.formElementSections = createApplicationFormDto.formElementSections
.map { formElementSectionMapper.toFormElementSection(it, applicationForm) } .map { formElementSectionMapper.toFormElementSection(it, applicationForm) }

View File

@@ -5,9 +5,11 @@ import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotCreatedExc
import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotDeletedException import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotDeletedException
import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotFoundException import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotFoundException
import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotUpdatedException import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotUpdatedException
import com.betriebsratkanzlei.legalconsenthub.notification.NotificationService
import com.betriebsratkanzlei.legalconsenthub_api.model.ApplicationFormDto import com.betriebsratkanzlei.legalconsenthub_api.model.ApplicationFormDto
import com.betriebsratkanzlei.legalconsenthub_api.model.ApplicationFormStatus import com.betriebsratkanzlei.legalconsenthub_api.model.ApplicationFormStatus
import com.betriebsratkanzlei.legalconsenthub_api.model.CreateApplicationFormDto import com.betriebsratkanzlei.legalconsenthub_api.model.CreateApplicationFormDto
import com.betriebsratkanzlei.legalconsenthub_api.model.NotificationType
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
@@ -16,7 +18,8 @@ import java.util.UUID
@Service @Service
class ApplicationFormService( class ApplicationFormService(
private val applicationFormRepository: ApplicationFormRepository, private val applicationFormRepository: ApplicationFormRepository,
private val applicationFormMapper: ApplicationFormMapper private val applicationFormMapper: ApplicationFormMapper,
private val notificationService: NotificationService
) { ) {
fun createApplicationForm(createApplicationFormDto: CreateApplicationFormDto): ApplicationForm { fun createApplicationForm(createApplicationFormDto: CreateApplicationFormDto): ApplicationForm {
@@ -76,10 +79,61 @@ class ApplicationFormService(
applicationForm.status = ApplicationFormStatus.SUBMITTED applicationForm.status = ApplicationFormStatus.SUBMITTED
return try { val savedApplicationForm = try {
applicationFormRepository.save(applicationForm) applicationFormRepository.save(applicationForm)
} catch (e: Exception) { } catch (e: Exception) {
throw ApplicationFormNotUpdatedException(e, id) throw ApplicationFormNotUpdatedException(e, id)
} }
// Create notifications for relevant users
createSubmissionNotifications(savedApplicationForm)
return savedApplicationForm
}
private fun createSubmissionNotifications(applicationForm: ApplicationForm) {
val title = "Neuer Mitbestimmungsantrag eingereicht"
val message = "Ein neuer Mitbestimmungsantrag '${applicationForm.name}' wurde von ${applicationForm.createdBy.name} eingereicht und wartet auf Ihre Bearbeitung."
val clickTarget = "/application-forms/${applicationForm.id}/0"
// Create notification for admin users
notificationService.createNotificationForUser(
title = title,
message = message,
clickTarget = clickTarget,
recipient = null,
targetGroup = "admin",
type = NotificationType.INFO
)
// Create notification for works council members
notificationService.createNotificationForUser(
title = title,
message = message,
clickTarget = clickTarget,
recipient = null,
targetGroup = "works_council_member",
type = NotificationType.INFO
)
// Create notification for employer
notificationService.createNotificationForUser(
title = title,
message = message,
clickTarget = clickTarget,
recipient = null,
targetGroup = "employer",
type = NotificationType.INFO
)
// Create notification for employee
notificationService.createNotificationForUser(
title = title,
message = message,
clickTarget = clickTarget,
recipient = null,
targetGroup = "employee",
type = NotificationType.INFO
)
} }
} }

View File

@@ -3,10 +3,7 @@ package com.betriebsratkanzlei.legalconsenthub.comment;
import com.betriebsratkanzlei.legalconsenthub.application_form.ApplicationForm import com.betriebsratkanzlei.legalconsenthub.application_form.ApplicationForm
import com.betriebsratkanzlei.legalconsenthub.form_element.FormElement import com.betriebsratkanzlei.legalconsenthub.form_element.FormElement
import com.betriebsratkanzlei.legalconsenthub.user.User import com.betriebsratkanzlei.legalconsenthub.user.User
import jakarta.persistence.AttributeOverride
import jakarta.persistence.AttributeOverrides
import jakarta.persistence.Column import jakarta.persistence.Column
import jakarta.persistence.Embedded
import jakarta.persistence.Entity import jakarta.persistence.Entity
import jakarta.persistence.EntityListeners import jakarta.persistence.EntityListeners
import jakarta.persistence.GeneratedValue; import jakarta.persistence.GeneratedValue;
@@ -30,11 +27,8 @@ class Comment(
@Column(nullable = false) @Column(nullable = false)
var message: String = "", var message: String = "",
@Embedded @ManyToOne
@AttributeOverrides( @JoinColumn(name = "created_by_id", nullable = false)
AttributeOverride(name = "id", column = Column(name = "created_by_id", nullable = false)),
AttributeOverride(name = "name", column = Column(name = "created_by_name", nullable = false))
)
var createdBy: User, var createdBy: User,
@CreatedDate @CreatedDate

View File

@@ -5,6 +5,7 @@ import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotFoundExcep
import com.betriebsratkanzlei.legalconsenthub.error.FormElementNotFoundException import com.betriebsratkanzlei.legalconsenthub.error.FormElementNotFoundException
import com.betriebsratkanzlei.legalconsenthub.form_element.FormElementRepository import com.betriebsratkanzlei.legalconsenthub.form_element.FormElementRepository
import com.betriebsratkanzlei.legalconsenthub.user.UserMapper import com.betriebsratkanzlei.legalconsenthub.user.UserMapper
import com.betriebsratkanzlei.legalconsenthub.user.UserService
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 org.springframework.stereotype.Component import org.springframework.stereotype.Component
@@ -14,6 +15,7 @@ import java.util.UUID
@Component @Component
class CommentMapper( class CommentMapper(
private val userMapper: UserMapper, private val userMapper: UserMapper,
private val userService: UserService,
private val applicationFormRepository: ApplicationFormRepository, private val applicationFormRepository: ApplicationFormRepository,
private val formElementRepository: FormElementRepository private val formElementRepository: FormElementRepository
) { ) {
@@ -53,9 +55,11 @@ class CommentMapper(
val formElement = formElementRepository.findById(formElementId) val formElement = formElementRepository.findById(formElementId)
.orElseThrow { FormElementNotFoundException(formElementId) } .orElseThrow { FormElementNotFoundException(formElementId) }
val currentUser = userService.getCurrentUser()
return Comment( return Comment(
message = commentDto.message, message = commentDto.message,
createdBy = userMapper.toUser(commentDto.createdBy), createdBy = currentUser,
applicationForm = applicationForm, applicationForm = applicationForm,
formElement = formElement formElement = formElement
) )

View File

@@ -9,6 +9,7 @@ import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm
import org.springframework.security.oauth2.jwt.JwtDecoder import org.springframework.security.oauth2.jwt.JwtDecoder
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder import org.springframework.security.oauth2.jwt.NimbusJwtDecoder
import org.springframework.security.web.SecurityFilterChain import org.springframework.security.web.SecurityFilterChain
import org.springframework.http.HttpMethod
@Configuration @Configuration
@EnableWebSecurity @EnableWebSecurity
@@ -24,6 +25,8 @@ class SecurityConfig {
authorizeHttpRequests { authorizeHttpRequests {
authorize("/swagger-ui/**", permitAll) authorize("/swagger-ui/**", permitAll)
authorize("/v3/**", permitAll) authorize("/v3/**", permitAll)
// For user registration
authorize(HttpMethod.POST, "/users", permitAll)
authorize(anyRequest, authenticated) authorize(anyRequest, authenticated)
} }
oauth2ResourceServer { oauth2ResourceServer {

View File

@@ -0,0 +1,40 @@
package com.betriebsratkanzlei.legalconsenthub.config
import com.github.dockerjava.api.model.ExposedPort
import com.github.dockerjava.api.model.HostConfig
import com.github.dockerjava.api.model.PortBinding
import com.github.dockerjava.api.model.Ports
import org.springframework.boot.testcontainers.service.connection.ServiceConnection
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.Profile
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.utility.DockerImageName
@Configuration
@Profile("testcontainers")
class TestContainersConfig {
@Bean
@ServiceConnection
fun postgresContainer(): PostgreSQLContainer<*> {
return PostgreSQLContainer(DockerImageName.parse("postgres:17-alpine"))
.withDatabaseName("legalconsenthub")
.withUsername("legalconsenthub")
.withPassword("legalconsenthub")
.withExposedPorts(5432)
.withCreateContainerCmdModifier { cmd ->
cmd.withHostConfig(
HostConfig().apply {
this.withPortBindings(
PortBinding(
Ports.Binding.bindPort(5432),
ExposedPort(5432)
)
)
}
)
}
.withReuse(true)
}
}

View File

@@ -16,7 +16,7 @@ class ExceptionHandler {
var logger = LoggerFactory.getLogger(ExceptionHandler::class.java) var logger = LoggerFactory.getLogger(ExceptionHandler::class.java)
@ResponseBody @ResponseBody
@ExceptionHandler(ApplicationFormNotFoundException::class) @ExceptionHandler(ApplicationFormNotFoundException::class, UserNotFoundException::class)
@ResponseStatus(HttpStatus.NOT_FOUND) @ResponseStatus(HttpStatus.NOT_FOUND)
fun handleNotFoundError(e: Exception): ResponseEntity<ProblemDetails> { fun handleNotFoundError(e: Exception): ResponseEntity<ProblemDetails> {
logger.warn(e.message, e) logger.warn(e.message, e)
@@ -47,6 +47,22 @@ class ExceptionHandler {
) )
} }
@ResponseBody
@ExceptionHandler(UserAlreadyExistsException::class)
@ResponseStatus(HttpStatus.CONFLICT)
fun handleUserAlreadyExistsError(e: UserAlreadyExistsException): ResponseEntity<ProblemDetails> {
logger.warn(e.message, e)
return ResponseEntity.status(HttpStatus.CONFLICT).contentType(MediaType.APPLICATION_PROBLEM_JSON)
.body(
ProblemDetails(
title = "Conflict",
status = HttpStatus.CONFLICT.value(),
type = URI.create("about:blank"),
detail = e.message ?: "Resource already exists"
)
)
}
@ResponseBody @ResponseBody
@ExceptionHandler( @ExceptionHandler(
ApplicationFormNotCreatedException::class, ApplicationFormNotCreatedException::class,

View File

@@ -0,0 +1,3 @@
package com.betriebsratkanzlei.legalconsenthub.error
class UserAlreadyExistsException(id: String): RuntimeException("User with ID $id already exists")

View File

@@ -0,0 +1,3 @@
package com.betriebsratkanzlei.legalconsenthub.error
class UserNotFoundException(id: String): RuntimeException("Couldn't find user with ID: $id")

View File

@@ -0,0 +1,52 @@
package com.betriebsratkanzlei.legalconsenthub.notification
import com.betriebsratkanzlei.legalconsenthub.user.User
import com.betriebsratkanzlei.legalconsenthub_api.model.NotificationType
import jakarta.persistence.Column
import jakarta.persistence.JoinColumn
import jakarta.persistence.ManyToOne
import jakarta.persistence.Entity
import jakarta.persistence.EntityListeners
import jakarta.persistence.Enumerated
import jakarta.persistence.EnumType
import jakarta.persistence.GeneratedValue
import jakarta.persistence.Id
import org.springframework.data.annotation.CreatedDate
import org.springframework.data.jpa.domain.support.AuditingEntityListener
import java.time.LocalDateTime
import java.util.UUID
@Entity
@EntityListeners(AuditingEntityListener::class)
class Notification(
@Id
@GeneratedValue
var id: UUID? = null,
@Column(nullable = false)
var title: String = "",
@Column(nullable = false, columnDefinition = "TEXT")
var message: String = "",
@Column(nullable = false)
var clickTarget: String = "",
@Column(nullable = false)
var isRead: Boolean = false,
@ManyToOne
@JoinColumn(name = "recipient_id", nullable = true)
var recipient: User?,
@Column(nullable = false)
var targetGroup: String = "",
@Enumerated(EnumType.STRING)
@Column(nullable = false)
var type: NotificationType = NotificationType.INFO,
@CreatedDate
@Column(nullable = false)
var createdAt: LocalDateTime? = null
)

View File

@@ -0,0 +1,102 @@
package com.betriebsratkanzlei.legalconsenthub.notification
import com.betriebsratkanzlei.legalconsenthub.security.CustomJwtTokenPrincipal
import com.betriebsratkanzlei.legalconsenthub.user.UserService
import com.betriebsratkanzlei.legalconsenthub_api.api.NotificationApi
import com.betriebsratkanzlei.legalconsenthub_api.model.CreateNotificationDto
import com.betriebsratkanzlei.legalconsenthub_api.model.NotificationDto
import com.betriebsratkanzlei.legalconsenthub_api.model.PagedNotificationDto
import org.springframework.http.ResponseEntity
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.web.bind.annotation.RestController
import java.util.UUID
@RestController
class NotificationController(
private val notificationService: NotificationService,
private val notificationMapper: NotificationMapper,
private val pagedNotificationMapper: PagedNotificationMapper,
private val userService: UserService
) : NotificationApi {
override fun getNotifications(page: Int, size: Int): ResponseEntity<PagedNotificationDto> {
val principal = SecurityContextHolder.getContext().authentication.principal as CustomJwtTokenPrincipal
val recipientId = principal.id ?: throw IllegalStateException("User ID not found")
val user = userService.getUserById(recipientId)
val notifications = if (user.role != null) {
notificationService.getNotificationsForUserAndGroup(
recipientId = recipientId,
userRole = user.role!!.value,
page = page,
size = size
)
} else {
notificationService.getNotificationsForUser(
recipientId = recipientId,
page = page,
size = size
)
}
return ResponseEntity.ok(pagedNotificationMapper.toPagedNotificationDto(notifications))
}
override fun getUnreadNotifications(): ResponseEntity<List<NotificationDto>> {
val principal = SecurityContextHolder.getContext().authentication.principal as CustomJwtTokenPrincipal
val recipientId = principal.id ?: throw IllegalStateException("User ID not found")
val user = userService.getUserById(recipientId)
val notifications = if (user.role != null) {
notificationService.getUnreadNotificationsForUserAndGroup(recipientId, user.role!!.value)
} else {
notificationService.getUnreadNotificationsForUser(recipientId)
}
return ResponseEntity.ok(notifications.map { notificationMapper.toNotificationDto(it) })
}
override fun getUnreadNotificationCount(): ResponseEntity<Long> {
val principal = SecurityContextHolder.getContext().authentication.principal as CustomJwtTokenPrincipal
val recipientId = principal.id ?: throw IllegalStateException("User ID not found")
val user = userService.getUserById(recipientId)
val count = if (user.role != null) {
notificationService.getUnreadNotificationCountForUserAndGroup(recipientId, user.role!!.value)
} else {
notificationService.getUnreadNotificationCount(recipientId)
}
return ResponseEntity.ok(count)
}
override fun markAllNotificationsAsRead(): ResponseEntity<Unit> {
val principal = SecurityContextHolder.getContext().authentication.principal as CustomJwtTokenPrincipal
val recipientId = principal.id ?: throw IllegalStateException("User ID not found")
val user = userService.getUserById(recipientId)
if (user.role != null) {
notificationService.markAllAsReadForUserAndGroup(recipientId, user.role!!.value)
} else {
notificationService.markAllAsRead(recipientId)
}
return ResponseEntity.noContent().build()
}
override fun markNotificationAsRead(id: UUID): ResponseEntity<NotificationDto> {
val notification = notificationService.markAsRead(id)
?: return ResponseEntity.notFound().build()
return ResponseEntity.ok(notificationMapper.toNotificationDto(notification))
}
override fun createNotification(createNotificationDto: CreateNotificationDto): ResponseEntity<NotificationDto> {
val notification = notificationService.createNotification(createNotificationDto)
return ResponseEntity.ok(notificationMapper.toNotificationDto(notification))
}
}

View File

@@ -0,0 +1,38 @@
package com.betriebsratkanzlei.legalconsenthub.notification
import com.betriebsratkanzlei.legalconsenthub.user.UserMapper
import com.betriebsratkanzlei.legalconsenthub_api.model.CreateNotificationDto
import com.betriebsratkanzlei.legalconsenthub_api.model.NotificationDto
import org.springframework.stereotype.Component
@Component
class NotificationMapper(
private val userMapper: UserMapper
) {
fun toNotificationDto(notification: Notification): NotificationDto {
return NotificationDto(
id = notification.id!!,
title = notification.title,
message = notification.message,
clickTarget = notification.clickTarget,
isRead = notification.isRead,
recipient = notification.recipient?.let { userMapper.toUserDto(it) },
targetGroup = notification.targetGroup,
type = notification.type,
createdAt = notification.createdAt!!
)
}
fun toNotification(createNotificationDto: CreateNotificationDto): Notification {
return Notification(
title = createNotificationDto.title,
message = createNotificationDto.message,
clickTarget = createNotificationDto.clickTarget,
isRead = false,
recipient = createNotificationDto.recipient?.let { userMapper.toUser(it) },
targetGroup = createNotificationDto.targetGroup,
type = createNotificationDto.type
)
}
}

View File

@@ -0,0 +1,47 @@
package com.betriebsratkanzlei.legalconsenthub.notification
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Modifying
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import org.springframework.stereotype.Repository
import java.util.UUID
@Repository
interface NotificationRepository : JpaRepository<Notification, UUID> {
fun findByRecipientIdOrderByCreatedAtDesc(recipientId: String?, pageable: Pageable): Page<Notification>
fun findByRecipientIdAndIsReadFalseOrderByCreatedAtDesc(recipientId: String?): List<Notification>
fun findByRecipientIsNullOrderByCreatedAtDesc(pageable: Pageable): Page<Notification>
fun findByRecipientIsNullAndIsReadFalseOrderByCreatedAtDesc(): List<Notification>
@Query("SELECT n FROM Notification n WHERE (n.recipient.id = :recipientId) OR (n.recipient IS NULL AND n.targetGroup = :targetGroup) OR (n.recipient IS NULL AND n.targetGroup = 'all') ORDER BY n.createdAt DESC")
fun findByRecipientIdOrTargetGroupOrderByCreatedAtDesc(@Param("recipientId") recipientId: String, @Param("targetGroup") targetGroup: String, pageable: Pageable): Page<Notification>
@Query("SELECT n FROM Notification n WHERE ((n.recipient.id = :recipientId) OR (n.recipient IS NULL AND n.targetGroup = :targetGroup) OR (n.recipient IS NULL AND n.targetGroup = 'all')) AND n.isRead = false ORDER BY n.createdAt DESC")
fun findByRecipientIdOrTargetGroupAndIsReadFalseOrderByCreatedAtDesc(@Param("recipientId") recipientId: String, @Param("targetGroup") targetGroup: String): List<Notification>
@Query("SELECT COUNT(n) FROM Notification n WHERE ((n.recipient.id = :recipientId) OR (n.recipient IS NULL AND n.targetGroup = :targetGroup) OR (n.recipient IS NULL AND n.targetGroup = 'all')) AND n.isRead = false")
fun countByRecipientIdOrTargetGroupAndIsReadFalse(@Param("recipientId") recipientId: String, @Param("targetGroup") targetGroup: String): Long
@Modifying
@Query("UPDATE Notification n SET n.isRead = true WHERE n.recipient.id = :recipientId")
fun markAllAsReadByRecipientId(@Param("recipientId") recipientId: String)
@Modifying
@Query("UPDATE Notification n SET n.isRead = true WHERE n.recipient IS NULL")
fun markAllAsReadForNullRecipients()
@Modifying
@Query("UPDATE Notification n SET n.isRead = true WHERE (n.recipient.id = :recipientId) OR (n.recipient IS NULL AND n.targetGroup = :targetGroup) OR (n.recipient IS NULL AND n.targetGroup = 'all')")
fun markAllAsReadByRecipientIdOrTargetGroup(@Param("recipientId") recipientId: String, @Param("targetGroup") targetGroup: String)
fun countByRecipientIdAndIsReadFalse(recipientId: String?): Long
fun countByRecipientIsNullAndIsReadFalse(): Long
}

View File

@@ -0,0 +1,87 @@
package com.betriebsratkanzlei.legalconsenthub.notification
import com.betriebsratkanzlei.legalconsenthub.user.User
import com.betriebsratkanzlei.legalconsenthub_api.model.CreateNotificationDto
import com.betriebsratkanzlei.legalconsenthub_api.model.NotificationType
import org.springframework.data.domain.Page
import org.springframework.data.domain.PageRequest
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.util.UUID
@Service
class NotificationService(
private val notificationRepository: NotificationRepository,
private val notificationMapper: NotificationMapper
) {
fun createNotification(createNotificationDto: CreateNotificationDto): Notification {
val notification = notificationMapper.toNotification(createNotificationDto)
return notificationRepository.save(notification)
}
fun createNotificationForUser(
title: String,
message: String,
clickTarget: String,
recipient: User?,
targetGroup: String,
type: NotificationType = NotificationType.INFO
): Notification {
val notification = Notification(
title = title,
message = message,
clickTarget = clickTarget,
recipient = recipient,
targetGroup = targetGroup,
type = type
)
return notificationRepository.save(notification)
}
fun getNotificationsForUser(recipientId: String, page: Int = 0, size: Int = 20): Page<Notification> {
val pageable = PageRequest.of(page, size)
return notificationRepository.findByRecipientIdOrderByCreatedAtDesc(recipientId, pageable)
}
fun getNotificationsForUserAndGroup(recipientId: String, userRole: String, page: Int = 0, size: Int = 20): Page<Notification> {
val pageable = PageRequest.of(page, size)
return notificationRepository.findByRecipientIdOrTargetGroupOrderByCreatedAtDesc(recipientId, userRole, pageable)
}
fun getUnreadNotificationsForUser(recipientId: String): List<Notification> {
return notificationRepository.findByRecipientIdAndIsReadFalseOrderByCreatedAtDesc(recipientId)
}
fun getUnreadNotificationsForUserAndGroup(recipientId: String, userRole: String): List<Notification> {
return notificationRepository.findByRecipientIdOrTargetGroupAndIsReadFalseOrderByCreatedAtDesc(recipientId, userRole)
}
fun getUnreadNotificationCount(recipientId: String): Long {
return notificationRepository.countByRecipientIdAndIsReadFalse(recipientId)
}
fun getUnreadNotificationCountForUserAndGroup(recipientId: String, userRole: String): Long {
return notificationRepository.countByRecipientIdOrTargetGroupAndIsReadFalse(recipientId, userRole)
}
@Transactional
fun markAllAsRead(recipientId: String) {
notificationRepository.markAllAsReadByRecipientId(recipientId)
}
@Transactional
fun markAllAsReadForUserAndGroup(recipientId: String, userRole: String) {
notificationRepository.markAllAsReadByRecipientIdOrTargetGroup(recipientId, userRole)
}
fun markAsRead(notificationId: UUID): Notification? {
val notification = notificationRepository.findById(notificationId).orElse(null)
return if (notification != null) {
notification.isRead = true
notificationRepository.save(notification)
} else {
null
}
}
}

View File

@@ -0,0 +1,25 @@
package com.betriebsratkanzlei.legalconsenthub.notification
import com.betriebsratkanzlei.legalconsenthub_api.model.PagedNotificationDto
import org.springframework.data.domain.Page
import org.springframework.stereotype.Component
@Component
class PagedNotificationMapper(
private val notificationMapper: NotificationMapper
) {
fun toPagedNotificationDto(page: Page<Notification>): PagedNotificationDto {
return PagedNotificationDto(
content = page.content.map { notificationMapper.toNotificationDto(it) },
number = page.number,
propertySize = page.size,
numberOfElements = page.numberOfElements,
totalElements = page.totalElements,
totalPages = page.totalPages,
first = page.isFirst,
last = page.isLast,
empty = page.isEmpty
)
}
}

View File

@@ -1,9 +1,37 @@
package com.betriebsratkanzlei.legalconsenthub.user package com.betriebsratkanzlei.legalconsenthub.user
import jakarta.persistence.Embeddable import com.betriebsratkanzlei.legalconsenthub_api.model.UserRole
import com.betriebsratkanzlei.legalconsenthub_api.model.UserStatus
import jakarta.persistence.*
import org.springframework.data.annotation.CreatedDate
import org.springframework.data.annotation.LastModifiedDate
import org.springframework.data.jpa.domain.support.AuditingEntityListener
import java.time.LocalDateTime
@Embeddable @Entity
@EntityListeners(AuditingEntityListener::class)
@Table(name = "app_user")
class User( class User(
@Id
@Column(nullable = false)
var id: String,
@Column(nullable = false)
var name: String, var name: String,
var id: String
@Enumerated(EnumType.STRING)
@Column(nullable = false)
var status: UserStatus = UserStatus.ACTIVE,
@Enumerated(EnumType.STRING)
@Column(nullable = true)
var role: UserRole? = null,
@CreatedDate
@Column(nullable = false)
var createdAt: LocalDateTime? = null,
@LastModifiedDate
@Column(nullable = false)
var modifiedAt: LocalDateTime? = null
) )

View File

@@ -0,0 +1,29 @@
package com.betriebsratkanzlei.legalconsenthub.user
import com.betriebsratkanzlei.legalconsenthub_api.api.UserApi
import com.betriebsratkanzlei.legalconsenthub_api.model.CreateUserDto
import com.betriebsratkanzlei.legalconsenthub_api.model.UserDto
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.RestController
@RestController
class UserController(
private val userService: UserService,
private val userMapper: UserMapper
) : UserApi {
override fun createUser(createUserDto: CreateUserDto): ResponseEntity<UserDto> {
val user = userService.createUser(createUserDto)
return ResponseEntity.status(201).body(userMapper.toUserDto(user))
}
override fun getUserById(id: String): ResponseEntity<UserDto> {
val user = userService.getUserById(id)
return ResponseEntity.ok(userMapper.toUserDto(user))
}
override fun deleteUser(id: String): ResponseEntity<Unit> {
userService.deleteUser(id)
return ResponseEntity.noContent().build()
}
}

View File

@@ -9,6 +9,8 @@ class UserMapper() {
return UserDto( return UserDto(
id = user.id, id = user.id,
name = user.name, name = user.name,
status = user.status,
role = user.role
) )
} }
@@ -16,6 +18,8 @@ class UserMapper() {
return User( return User(
id = userDto.id, id = userDto.id,
name = userDto.name, name = userDto.name,
status = userDto.status,
role = userDto.role
) )
} }
} }

View File

@@ -0,0 +1,7 @@
package com.betriebsratkanzlei.legalconsenthub.user
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository
interface UserRepository : JpaRepository<User, String>

View File

@@ -0,0 +1,46 @@
package com.betriebsratkanzlei.legalconsenthub.user
import com.betriebsratkanzlei.legalconsenthub.error.UserAlreadyExistsException
import com.betriebsratkanzlei.legalconsenthub.error.UserNotFoundException
import com.betriebsratkanzlei.legalconsenthub.security.CustomJwtTokenPrincipal
import com.betriebsratkanzlei.legalconsenthub_api.model.CreateUserDto
import com.betriebsratkanzlei.legalconsenthub_api.model.UserStatus
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.stereotype.Service
@Service
class UserService(
private val userRepository: UserRepository
) {
fun getCurrentUser(): User {
val principal = SecurityContextHolder.getContext().authentication.principal as CustomJwtTokenPrincipal
val userId = principal.id ?: throw IllegalStateException("User ID not found")
return userRepository.findById(userId)
.orElseThrow { UserNotFoundException(userId) }
}
fun createUser(createUserDto: CreateUserDto): User {
if (userRepository.existsById(createUserDto.id)) {
throw UserAlreadyExistsException(createUserDto.id)
}
val user = User(
id = createUserDto.id,
name = createUserDto.name,
status = createUserDto.status ?: UserStatus.ACTIVE,
role = createUserDto.role
)
return userRepository.save(user)
}
fun getUserById(userId: String): User {
return userRepository.findById(userId)
.orElseThrow { UserNotFoundException(userId) }
}
fun deleteUser(userId: String) {
userRepository.deleteById(userId)
}
}

View File

@@ -0,0 +1,35 @@
spring:
application:
name: legalconsenthub
jpa:
database-platform: org.hibernate.dialect.PostgreSQLDialect
hibernate:
ddl-auto: create
show-sql: true
properties:
hibernate:
format_sql: true
jdbc:
batch_size: 100
order_inserts: true
enable_lazy_load_no_trans: true
liquibase:
enabled: true
drop-first: false
change-log: classpath:/db/changelog/db.changelog-master.yaml
default-schema: public
sql:
init:
platform: postgresql
logging:
level:
org:
springframework:
security: TRACE
oauth2: TRACE
org.testcontainers: INFO
com.github.dockerjava: WARN

View File

@@ -1,3 +1,14 @@
create table app_user
(
created_at timestamp(6) not null,
modified_at timestamp(6) not null,
id varchar(255) not null,
name varchar(255) not null,
role varchar(255),
status varchar(255) not null check (status in ('INVITED', 'ACTIVE', 'BLOCKED', 'SUSPENDED_SUBSCRIPTION')),
primary key (id)
);
create table application_form create table application_form
( (
is_template boolean not null, is_template boolean not null,
@@ -5,12 +16,10 @@ create table application_form
modified_at timestamp(6) not null, modified_at timestamp(6) not null,
id uuid not null, id uuid not null,
created_by_id varchar(255) not null, created_by_id varchar(255) not null,
created_by_name varchar(255) not null,
last_modified_by_id varchar(255) not null, last_modified_by_id varchar(255) not null,
last_modified_by_name varchar(255) not null,
name varchar(255) not null, name varchar(255) not null,
organization_id varchar(255), organization_id varchar(255),
status enum ('APPROVED','DRAFT','REJECTED','SIGNED','SUBMITTED') not null, status varchar(255) not null check (status in ('DRAFT', 'SUBMITTED', 'APPROVED', 'REJECTED', 'SIGNED')),
primary key (id) primary key (id)
); );
@@ -22,15 +31,14 @@ create table comment
form_element_id uuid not null, form_element_id uuid not null,
id uuid not null, id uuid not null,
created_by_id varchar(255) not null, created_by_id varchar(255) not null,
created_by_name varchar(255) not null,
message varchar(255) not null, message varchar(255) not null,
primary key (id) primary key (id)
); );
create table form_element_options create table form_element_options
( (
employee_data_category tinyint not null check (employee_data_category between 0 and 3), employee_data_category smallint not null check (employee_data_category between 0 and 3),
processing_purpose tinyint not null check (processing_purpose between 0 and 3), processing_purpose smallint not null check (processing_purpose between 0 and 3),
form_element_id uuid not null, form_element_id uuid not null,
label varchar(255) not null, label varchar(255) not null,
option_value varchar(255) not null option_value varchar(255) not null
@@ -38,7 +46,7 @@ create table form_element_options
create table form_element create table form_element
( (
type tinyint not null check (type between 0 and 4), type smallint not null check (type between 0 and 4),
form_element_section_id uuid not null, form_element_section_id uuid not null,
id uuid not null, id uuid not null,
description varchar(255), description varchar(255),
@@ -56,11 +64,40 @@ create table form_element_section
primary key (id) primary key (id)
); );
create table notification
(
is_read boolean not null,
created_at timestamp(6) not null,
id uuid not null,
click_target varchar(255) not null,
message TEXT not null,
recipient_id varchar(255),
target_group varchar(255) not null,
title varchar(255) not null,
type varchar(255) not null check (type in ('INFO', 'WARNING', 'ERROR')),
primary key (id)
);
alter table if exists application_form
add constraint FKhtad5onoy2jknhtyfmx6cvvey
foreign key (created_by_id)
references app_user;
alter table if exists application_form
add constraint FK5yewx8bespw0uiivxioeh7q0d
foreign key (last_modified_by_id)
references app_user;
alter table if exists comment alter table if exists comment
add constraint FKlavy9axrt26sepreg5lqtuoap add constraint FKlavy9axrt26sepreg5lqtuoap
foreign key (application_form_id) foreign key (application_form_id)
references application_form; references application_form;
alter table if exists comment
add constraint FKbbjqikfmgeacfsnaasxxqoygh
foreign key (created_by_id)
references app_user;
alter table if exists comment alter table if exists comment
add constraint FKfg84w0i76tw9os13950272c6f add constraint FKfg84w0i76tw9os13950272c6f
foreign key (form_element_id) foreign key (form_element_id)
@@ -80,3 +117,8 @@ alter table if exists form_element_section
add constraint FKtn0lreovauwf2v29doo70o3qs add constraint FKtn0lreovauwf2v29doo70o3qs
foreign key (application_form_id) foreign key (application_form_id)
references application_form; references application_form;
alter table if exists notification
add constraint FKeg1j4hnp0y4lbm0y35hgr4e8r
foreign key (recipient_id)
references app_user;

View File

@@ -0,0 +1,89 @@
<template>
<USlideover v-model:open="isOpen" title="Benachrichtigungen">
<template #body>
<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" />
<p>Keine Benachrichtigungen</p>
</div>
<NuxtLink
v-for="notification in notifications"
:key="notification.id"
:to="notification.clickTarget"
class="px-3 py-2.5 rounded-md hover:bg-elevated/50 flex items-center gap-3 relative -mx-3 first:-mt-3 last:-mb-3"
@click="handleNotificationClick(notification)"
>
<UChip
:color="notification.type === 'ERROR' ? 'error' : notification.type === 'WARNING' ? 'warning' : 'primary'"
:show="!notification.isRead"
inset
>
<UIcon
:name="
notification.type === 'ERROR'
? 'i-heroicons-x-circle'
: notification.type === 'WARNING'
? 'i-heroicons-exclamation-triangle'
: 'i-heroicons-information-circle'
"
class="h-6 w-6"
/>
</UChip>
<div class="text-sm flex-1">
<p class="flex items-center justify-between">
<span class="text-highlighted font-medium">{{ notification.title }}</span>
<time
:datetime="notification.createdAt.toISOString()"
class="text-muted text-xs"
v-text="formatTimeAgo(notification.createdAt)"
/>
</p>
<p class="text-dimmed">
{{ notification.message }}
</p>
<div class="flex items-center gap-2 mt-1">
<UBadge
:color="notification.type === 'ERROR' ? 'error' : notification.type === 'WARNING' ? 'warning' : 'info'"
variant="subtle"
size="xs"
>
{{ notification.type }}
</UBadge>
<UBadge color="neutral" variant="subtle" size="xs">
{{ notification.targetGroup }}
</UBadge>
</div>
</div>
</NuxtLink>
</template>
</USlideover>
</template>
<script setup lang="ts">
import { formatTimeAgo } from '@vueuse/core'
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
const props = defineProps<{
modelValue: boolean
}>()
const isOpen = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
const { notifications, fetchNotifications, handleNotificationClick } = useNotification()
watch(isOpen, async (newValue) => {
if (newValue) {
await fetchNotifications()
}
})
</script>

View File

@@ -1,2 +1,6 @@
export { useApplicationFormTemplate } from './applicationFormTemplate/useApplicationFormTemplate' export { useApplicationFormTemplate } from './applicationFormTemplate/useApplicationFormTemplate'
export { useApplicationForm } from './applicationForm/useApplicationForm' export { useApplicationForm } from './applicationForm/useApplicationForm'
export { useNotification } from './notification/useNotification'
export { useNotificationApi } from './notification/useNotificationApi'
export { useUser } from './user/useUser'
export { useUserApi } from './user/useUserApi'

View File

@@ -0,0 +1,118 @@
import type { NotificationDto } from '~/.api-client'
export const useNotification = () => {
const {
getNotifications,
getUnreadNotifications,
getUnreadNotificationCount,
markAllNotificationsAsRead,
markNotificationAsRead
} = useNotificationApi()
const notifications = ref<NotificationDto[]>([])
const unreadNotifications = ref<NotificationDto[]>([])
const unreadCount = ref<number>(0)
const isLoading = ref(false)
const fetchNotifications = async (page: number = 0, size: number = 20) => {
isLoading.value = true
try {
const response = await getNotifications(page, size)
notifications.value = response.content || []
return response
} catch (error) {
console.error('Failed to fetch notifications:', error)
throw error
} finally {
isLoading.value = false
}
}
const fetchUnreadNotifications = async () => {
try {
const response = await getUnreadNotifications()
unreadNotifications.value = response || []
return response
} catch (error) {
console.error('Failed to fetch unread notifications:', error)
throw error
}
}
const fetchUnreadCount = async () => {
try {
const count = await getUnreadNotificationCount()
unreadCount.value = count || 0
return count
} catch (error) {
console.error('Failed to fetch unread count:', error)
throw error
}
}
const markAllAsRead = async () => {
try {
await markAllNotificationsAsRead()
unreadCount.value = 0
unreadNotifications.value = []
notifications.value = notifications.value.map((n) => ({ ...n, isRead: true }))
} catch (error) {
console.error('Failed to mark all as read:', error)
throw error
}
}
const markAsRead = async (notificationId: string) => {
try {
await markNotificationAsRead(notificationId)
const index = notifications.value.findIndex((n) => n.id === notificationId)
if (index !== -1) {
notifications.value[index].isRead = true
}
// Remove from unread notifications
unreadNotifications.value = unreadNotifications.value.filter((n) => n.id !== notificationId)
if (unreadCount.value > 0) {
unreadCount.value--
}
} catch (error) {
console.error('Failed to mark notification as read:', error)
throw error
}
}
const handleNotificationClick = async (notification: NotificationDto) => {
if (!notification.isRead) {
await markAsRead(notification.id)
}
if (notification.clickTarget) {
await navigateTo(notification.clickTarget)
}
}
const startPeriodicRefresh = (intervalMs: number = 30000) => {
const interval = setInterval(() => {
fetchUnreadCount()
}, intervalMs)
onUnmounted(() => {
clearInterval(interval)
})
return interval
}
return {
notifications: readonly(notifications),
unreadNotifications: readonly(unreadNotifications),
unreadCount: readonly(unreadCount),
isLoading: readonly(isLoading),
fetchNotifications,
fetchUnreadNotifications,
fetchUnreadCount,
markAllAsRead,
markAsRead,
handleNotificationClick,
startPeriodicRefresh
}
}

View File

@@ -0,0 +1,55 @@
import {
NotificationApi,
Configuration,
type NotificationDto,
type PagedNotificationDto,
type CreateNotificationDto
} from '~/.api-client'
import { cleanDoubleSlashes, withoutTrailingSlash } from 'ufo'
export function useNotificationApi() {
const appBaseUrl = useRuntimeConfig().app.baseURL
const { serverApiBaseUrl, serverApiBasePath, clientProxyBasePath } = useRuntimeConfig().public
const { jwt } = useAuth()
const basePath = withoutTrailingSlash(
cleanDoubleSlashes(import.meta.client ? appBaseUrl + clientProxyBasePath : serverApiBaseUrl + serverApiBasePath)
)
const notificationApiClient = new NotificationApi(
new Configuration({ basePath, headers: { Authorization: jwt.value ? `Bearer ${jwt.value}` : '' } })
)
async function createNotification(createNotificationDto: CreateNotificationDto): Promise<NotificationDto> {
return notificationApiClient.createNotification({ createNotificationDto })
}
async function getNotifications(page?: number, size?: number): Promise<PagedNotificationDto> {
return notificationApiClient.getNotifications({ page, size })
}
async function getUnreadNotifications(): Promise<NotificationDto[]> {
return notificationApiClient.getUnreadNotifications()
}
async function getUnreadNotificationCount(): Promise<number> {
return notificationApiClient.getUnreadNotificationCount()
}
async function markAllNotificationsAsRead(): Promise<void> {
return notificationApiClient.markAllNotificationsAsRead()
}
async function markNotificationAsRead(id: string): Promise<NotificationDto> {
return notificationApiClient.markNotificationAsRead({ id })
}
return {
createNotification,
getNotifications,
getUnreadNotifications,
getUnreadNotificationCount,
markAllNotificationsAsRead,
markNotificationAsRead
}
}

View File

@@ -13,6 +13,7 @@ import {
worksCouncilMemberRole, worksCouncilMemberRole,
employeeRole, employeeRole,
adminRole, adminRole,
ownerRole,
ROLES ROLES
} from '~/server/utils/permissions' } from '~/server/utils/permissions'
@@ -57,7 +58,8 @@ export function useAuth() {
[ROLES.EMPLOYER]: employerRole, [ROLES.EMPLOYER]: employerRole,
[ROLES.WORKS_COUNCIL_MEMBER]: worksCouncilMemberRole, [ROLES.WORKS_COUNCIL_MEMBER]: worksCouncilMemberRole,
[ROLES.EMPLOYEE]: employeeRole, [ROLES.EMPLOYEE]: employeeRole,
[ROLES.ADMIN]: adminRole [ROLES.ADMIN]: adminRole,
[ROLES.OWNER]: ownerRole
} }
}), }),
jwtClient() jwtClient()

View File

@@ -30,11 +30,11 @@ export function usePermissions() {
) )
const canInviteMembers = computed(() => const canInviteMembers = computed(() =>
hasPermission({ member: ["invite"] }) hasPermission({ invitation: ["create"] })
) )
const canManageOrganization = computed(() => const canManageOrganization = computed(() =>
hasPermission({ organization: ["manage_settings"] }) hasPermission({ organization: ["update"] })
) )
// Role checks // Role checks
@@ -42,6 +42,7 @@ export function usePermissions() {
const isEmployee = computed(() => currentRole.value === ROLES.EMPLOYEE) const isEmployee = computed(() => currentRole.value === ROLES.EMPLOYEE)
const isWorksCouncilMember = computed(() => currentRole.value === ROLES.WORKS_COUNCIL_MEMBER) const isWorksCouncilMember = computed(() => currentRole.value === ROLES.WORKS_COUNCIL_MEMBER)
const isAdmin = computed(() => currentRole.value === ROLES.ADMIN) const isAdmin = computed(() => currentRole.value === ROLES.ADMIN)
const isOwner = computed(() => currentRole.value === ROLES.OWNER)
const getCurrentRoleInfo = () => { const getCurrentRoleInfo = () => {
const roleInfo = { const roleInfo = {
@@ -68,6 +69,12 @@ export function usePermissions() {
description: 'Vollzugriff auf Organisationsverwaltung', description: 'Vollzugriff auf Organisationsverwaltung',
color: 'red', color: 'red',
icon: 'i-lucide-settings' icon: 'i-lucide-settings'
},
[ROLES.OWNER]: {
name: 'Eigentümer',
description: 'Vollzugriff und Organisationsbesitz',
color: 'yellow',
icon: 'i-lucide-crown'
} }
} }
@@ -87,6 +94,7 @@ export function usePermissions() {
isEmployee, isEmployee,
isWorksCouncilMember, isWorksCouncilMember,
isAdmin, isAdmin,
isOwner,
// Computed permissions // Computed permissions
canCreateApplicationForm, canCreateApplicationForm,

View File

@@ -0,0 +1,58 @@
import {
type CreateUserDto,
type UserDto,
ResponseError
} from '~/.api-client'
import { useUserApi } from './useUserApi'
export function useUser() {
const userApi = useUserApi()
async function createUser(createUserDto: CreateUserDto): Promise<UserDto> {
try {
return await userApi.createUser(createUserDto)
} catch (e: unknown) {
if (e instanceof ResponseError) {
console.error('Failed creating user:', e.response)
} else {
console.error('Failed creating user:', e)
}
return Promise.reject(e)
}
}
async function getUserById(id: string): Promise<UserDto | null> {
try {
return await userApi.getUserById(id)
} catch (e: unknown) {
if (e instanceof ResponseError && e.response.status === 404) {
return null
}
if (e instanceof ResponseError) {
console.error(`Failed retrieving user with ID ${id}:`, e.response)
} else {
console.error(`Failed retrieving user with ID ${id}:`, e)
}
return Promise.reject(e)
}
}
async function deleteUser(id: string): Promise<void> {
try {
return await userApi.deleteUser(id)
} catch (e: unknown) {
if (e instanceof ResponseError) {
console.error(`Failed deleting user with ID ${id}:`, e.response)
} else {
console.error(`Failed deleting user with ID ${id}:`, e)
}
return Promise.reject(e)
}
}
return {
createUser,
getUserById,
deleteUser
}
}

View File

@@ -0,0 +1,39 @@
import {
UserApi,
Configuration,
type CreateUserDto,
type UserDto
} from '~/.api-client'
import { cleanDoubleSlashes, withoutTrailingSlash } from 'ufo'
export function useUserApi() {
const appBaseUrl = useRuntimeConfig().app.baseURL
const { serverApiBaseUrl, serverApiBasePath, clientProxyBasePath } = useRuntimeConfig().public
const { jwt } = useAuth()
const basePath = withoutTrailingSlash(
cleanDoubleSlashes(import.meta.client ? appBaseUrl + clientProxyBasePath : serverApiBaseUrl + serverApiBasePath)
)
const userApiClient = new UserApi(
new Configuration({ basePath, headers: { Authorization: jwt.value ? `Bearer ${jwt.value}` : '' } })
)
async function createUser(createUserDto: CreateUserDto): Promise<UserDto> {
return userApiClient.createUser({ createUserDto })
}
async function getUserById(id: string): Promise<UserDto> {
return userApiClient.getUserById({ id })
}
async function deleteUser(id: string): Promise<void> {
return userApiClient.deleteUser({ id })
}
return {
createUser,
getUserById,
deleteUser
}
}

View File

@@ -29,10 +29,25 @@
</UDashboardSidebar> </UDashboardSidebar>
<slot /> <slot />
<NotificationsSlideover v-model="isNotificationsSlideoverOpen" />
</UDashboardGroup> </UDashboardGroup>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const links = [[], []] const links = [[], []]
const open = ref(false) const open = ref(false)
const isNotificationsSlideoverOpen = ref(false)
const { unreadCount, fetchUnreadCount, startPeriodicRefresh } = useNotification()
onMounted(async () => {
await fetchUnreadCount()
startPeriodicRefresh()
})
provide('notificationState', {
isNotificationsSlideoverOpen,
unreadCount
})
</script> </script>

View File

@@ -15,13 +15,15 @@
"api:generate": "openapi-generator-cli generate -i ../legalconsenthub-backend/api/legalconsenthub.yml -g typescript-fetch -o .api-client", "api:generate": "openapi-generator-cli generate -i ../legalconsenthub-backend/api/legalconsenthub.yml -g typescript-fetch -o .api-client",
"api:middleware:generate": "openapi-generator-cli generate -i ../legalconsenthub-middleware/api/legalconsenthub-middleware.yml -g typescript-fetch -o .api-client-middleware", "api:middleware:generate": "openapi-generator-cli generate -i ../legalconsenthub-middleware/api/legalconsenthub-middleware.yml -g typescript-fetch -o .api-client-middleware",
"fix:bettersqlite": "cd node_modules/better-sqlite3 && pnpm dlx node-gyp rebuild && cd ../..", "fix:bettersqlite": "cd node_modules/better-sqlite3 && pnpm dlx node-gyp rebuild && cd ../..",
"generate:betterauth": "pnpm dlx @better-auth/cli generate --config server/utils/auth.ts", "generate:betterauth": "pnpm dlx @better-auth/cli generate --config server/utils/auth.ts --yes",
"migrate:betterauth": "pnpm dlx @better-auth/cli migrate --config server/utils/auth.ts" "migrate:betterauth": "pnpm dlx @better-auth/cli migrate --config server/utils/auth.ts --yes",
"recreate-db:betterauth": "rm sqlite.db && pnpm run migrate:betterauth && pnpm run migrate:betterauth"
}, },
"dependencies": { "dependencies": {
"@nuxt/ui-pro": "3.1.1", "@nuxt/ui-pro": "3.1.1",
"@nuxtjs/i18n": "10.0.3", "@nuxtjs/i18n": "10.0.3",
"@pinia/nuxt": "0.10.1", "@pinia/nuxt": "0.10.1",
"@vueuse/core": "^13.6.0",
"better-auth": "1.3.4", "better-auth": "1.3.4",
"better-sqlite3": "11.8.1", "better-sqlite3": "11.8.1",
"nuxt": "3.16.1", "nuxt": "3.16.1",

View File

@@ -19,6 +19,16 @@
}" }"
class="w-48" class="w-48"
/> />
<UTooltip text="Notifications" :shortcuts="['N']">
<UButton color="neutral" variant="ghost" square @click="isNotificationsSlideoverOpen = true">
<UChip :show="unreadCount > 0" color="error" inset>
<UIcon name="i-lucide-bell" class="size-5 shrink-0" />
<span v-if="unreadCount > 0" class="ml-1 text-xs">{{ unreadCount }}</span>
</UChip>
</UButton>
</UTooltip>
<UDropdownMenu :items="items"> <UDropdownMenu :items="items">
<UButton icon="i-lucide-plus" size="md" class="rounded-full" /> <UButton icon="i-lucide-plus" size="md" class="rounded-full" />
</UDropdownMenu> </UDropdownMenu>
@@ -49,11 +59,7 @@
<p class="text-(--ui-text-muted) text-sm"> <p class="text-(--ui-text-muted) text-sm">
Erstellt von {{ applicationFormElem.createdBy.name }} am {{ formatDate(applicationFormElem.createdAt) }} Erstellt von {{ applicationFormElem.createdBy.name }} am {{ formatDate(applicationFormElem.createdAt) }}
</p> </p>
<div class="mt-2"> <p class="text-(--ui-text-muted) text-sm">Status: {{ applicationFormElem.status }}</p>
<UChip size="sm">
{{ applicationFormElem.status }}
</UChip>
</div>
</div> </div>
<div> <div>
<UPageLinks :links="getLinksForApplicationForm(applicationFormElem)" /> <UPageLinks :links="getLinksForApplicationForm(applicationFormElem)" />
@@ -77,6 +83,12 @@ const { getAllApplicationForms, deleteApplicationFormById } = useApplicationForm
const route = useRoute() const route = useRoute()
const { organizations, selectedOrganization } = useAuth() const { organizations, selectedOrganization } = useAuth()
// Inject notification state from layout
const { isNotificationsSlideoverOpen, unreadCount } = inject('notificationState', {
isNotificationsSlideoverOpen: ref(false),
unreadCount: ref(0)
})
const { data } = await useAsyncData<PagedApplicationFormDto>( const { data } = await useAsyncData<PagedApplicationFormDto>(
async () => { async () => {
if (!selectedOrganization.value) { if (!selectedOrganization.value) {

View File

@@ -20,6 +20,7 @@
<script setup lang="ts"> <script setup lang="ts">
import * as z from 'zod' import * as z from 'zod'
import type { FormSubmitEvent } from '@nuxt/ui' import type { FormSubmitEvent } from '@nuxt/ui'
import { UserRole } from '~/.api-client'
definePageMeta({ layout: 'auth' }) definePageMeta({ layout: 'auth' })
@@ -27,6 +28,7 @@ useSeoMeta({ title: 'Sign up' })
const toast = useToast() const toast = useToast()
const { signUp } = useAuth() const { signUp } = useAuth()
const { createUser } = useUser()
const fields = [ const fields = [
{ {
@@ -90,8 +92,28 @@ function onSubmit(payload: FormSubmitEvent<Schema>) {
// TODO: Hide loading spinner // TODO: Hide loading spinner
console.log('Receiving register response') console.log('Receiving register response')
}, },
onSuccess: async () => { onSuccess: async (ctx) => {
console.log('Successfully registered!') console.log('Successfully registered!')
// Create user in backend after successful Better Auth registration
try {
console.log('Creating user in backend...', ctx.data)
await createUser({
id: ctx.data.user.id,
name: ctx.data.user.name,
status: 'ACTIVE',
role: UserRole.Employee
})
console.log('User created in backend successfully')
} catch (error) {
console.error('Failed to create user in backend:', error)
toast.add({
title: 'Warning',
description: 'Account created but there was an issue with backend setup. Please contact support.',
color: 'warning'
})
}
await navigateTo('/') await navigateTo('/')
}, },
onError: (ctx) => { onError: (ctx) => {

View File

@@ -17,6 +17,9 @@ importers:
'@pinia/nuxt': '@pinia/nuxt':
specifier: 0.10.1 specifier: 0.10.1
version: 0.10.1(magicast@0.3.5)(pinia@3.0.1(typescript@5.7.3)(vue@3.5.13(typescript@5.7.3))) version: 0.10.1(magicast@0.3.5)(pinia@3.0.1(typescript@5.7.3)(vue@3.5.13(typescript@5.7.3)))
'@vueuse/core':
specifier: ^13.6.0
version: 13.6.0(vue@3.5.13(typescript@5.7.3))
better-auth: better-auth:
specifier: 1.3.4 specifier: 1.3.4
version: 1.3.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0) version: 1.3.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@@ -2143,6 +2146,11 @@ packages:
peerDependencies: peerDependencies:
vue: ^3.5.0 vue: ^3.5.0
'@vueuse/core@13.6.0':
resolution: {integrity: sha512-DJbD5fV86muVmBgS9QQPddVX7d9hWYswzlf4bIyUD2dj8GC46R1uNClZhVAmsdVts4xb2jwp1PbpuiA50Qee1A==}
peerDependencies:
vue: ^3.5.0
'@vueuse/integrations@13.1.0': '@vueuse/integrations@13.1.0':
resolution: {integrity: sha512-wJ6aANdUs4SOpVabChQK+uLIwxRTUAEmn1DJnflGG7Wq6yaipiRmp6as/Md201FjJnquQt8MecIPbFv8HSBeDA==} resolution: {integrity: sha512-wJ6aANdUs4SOpVabChQK+uLIwxRTUAEmn1DJnflGG7Wq6yaipiRmp6as/Md201FjJnquQt8MecIPbFv8HSBeDA==}
peerDependencies: peerDependencies:
@@ -2194,6 +2202,9 @@ packages:
'@vueuse/metadata@13.1.0': '@vueuse/metadata@13.1.0':
resolution: {integrity: sha512-+TDd7/a78jale5YbHX9KHW3cEDav1lz1JptwDvep2zSG8XjCsVE+9mHIzjTOaPbHUAk5XiE4jXLz51/tS+aKQw==} resolution: {integrity: sha512-+TDd7/a78jale5YbHX9KHW3cEDav1lz1JptwDvep2zSG8XjCsVE+9mHIzjTOaPbHUAk5XiE4jXLz51/tS+aKQw==}
'@vueuse/metadata@13.6.0':
resolution: {integrity: sha512-rnIH7JvU7NjrpexTsl2Iwv0V0yAx9cw7+clymjKuLSXG0QMcLD0LDgdNmXic+qL0SGvgSVPEpM9IDO/wqo1vkQ==}
'@vueuse/shared@10.11.1': '@vueuse/shared@10.11.1':
resolution: {integrity: sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==} resolution: {integrity: sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==}
@@ -2205,6 +2216,11 @@ packages:
peerDependencies: peerDependencies:
vue: ^3.5.0 vue: ^3.5.0
'@vueuse/shared@13.6.0':
resolution: {integrity: sha512-pDykCSoS2T3fsQrYqf9SyF0QXWHmcGPQ+qiOVjlYSzlWd9dgppB2bFSM1GgKKkt7uzn0BBMV3IbJsUfHG2+BCg==}
peerDependencies:
vue: ^3.5.0
abbrev@3.0.0: abbrev@3.0.0:
resolution: {integrity: sha512-+/kfrslGQ7TNV2ecmQwMJj/B65g5KVq1/L3SGVZ3tCYGqlzFuFCGBZJtMP99wH3NpEUyAjn0zPdPUg0D+DwrOA==} resolution: {integrity: sha512-+/kfrslGQ7TNV2ecmQwMJj/B65g5KVq1/L3SGVZ3tCYGqlzFuFCGBZJtMP99wH3NpEUyAjn0zPdPUg0D+DwrOA==}
engines: {node: ^18.17.0 || >=20.5.0} engines: {node: ^18.17.0 || >=20.5.0}
@@ -6974,7 +6990,7 @@ snapshots:
'@nuxt/schema': 3.17.2 '@nuxt/schema': 3.17.2
'@nuxt/ui': 3.1.1(@babel/parser@7.28.0)(axios@1.7.9)(db0@0.3.1(better-sqlite3@11.8.1))(embla-carousel@8.6.0)(ioredis@5.6.0)(magicast@0.3.5)(typescript@5.7.3)(vite@6.2.3(@types/node@22.13.14)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.39.0)(yaml@2.7.0))(vue-router@4.5.0(vue@3.5.13(typescript@5.7.3)))(vue@3.5.13(typescript@5.7.3))(zod@4.0.10) '@nuxt/ui': 3.1.1(@babel/parser@7.28.0)(axios@1.7.9)(db0@0.3.1(better-sqlite3@11.8.1))(embla-carousel@8.6.0)(ioredis@5.6.0)(magicast@0.3.5)(typescript@5.7.3)(vite@6.2.3(@types/node@22.13.14)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.39.0)(yaml@2.7.0))(vue-router@4.5.0(vue@3.5.13(typescript@5.7.3)))(vue@3.5.13(typescript@5.7.3))(zod@4.0.10)
'@standard-schema/spec': 1.0.0 '@standard-schema/spec': 1.0.0
'@vueuse/core': 13.1.0(vue@3.5.13(typescript@5.7.3)) '@vueuse/core': 13.6.0(vue@3.5.13(typescript@5.7.3))
consola: 3.4.2 consola: 3.4.2
defu: 6.1.4 defu: 6.1.4
dotenv: 16.5.0 dotenv: 16.5.0
@@ -6987,7 +7003,7 @@ snapshots:
tinyglobby: 0.2.13 tinyglobby: 0.2.13
typescript: 5.7.3 typescript: 5.7.3
unplugin: 2.3.2 unplugin: 2.3.2
unplugin-auto-import: 19.1.2(@nuxt/kit@3.17.2(magicast@0.3.5))(@vueuse/core@13.1.0(vue@3.5.13(typescript@5.7.3))) unplugin-auto-import: 19.1.2(@nuxt/kit@3.17.2(magicast@0.3.5))(@vueuse/core@13.6.0(vue@3.5.13(typescript@5.7.3)))
unplugin-vue-components: 28.5.0(@babel/parser@7.28.0)(@nuxt/kit@3.17.2(magicast@0.3.5))(vue@3.5.13(typescript@5.7.3)) unplugin-vue-components: 28.5.0(@babel/parser@7.28.0)(@nuxt/kit@3.17.2(magicast@0.3.5))(vue@3.5.13(typescript@5.7.3))
optionalDependencies: optionalDependencies:
zod: 4.0.10 zod: 4.0.10
@@ -7046,7 +7062,7 @@ snapshots:
'@tailwindcss/vite': 4.1.6(vite@6.2.3(@types/node@22.13.14)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.39.0)(yaml@2.7.0)) '@tailwindcss/vite': 4.1.6(vite@6.2.3(@types/node@22.13.14)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.39.0)(yaml@2.7.0))
'@tanstack/vue-table': 8.21.3(vue@3.5.13(typescript@5.7.3)) '@tanstack/vue-table': 8.21.3(vue@3.5.13(typescript@5.7.3))
'@unhead/vue': 2.0.8(vue@3.5.13(typescript@5.7.3)) '@unhead/vue': 2.0.8(vue@3.5.13(typescript@5.7.3))
'@vueuse/core': 13.1.0(vue@3.5.13(typescript@5.7.3)) '@vueuse/core': 13.6.0(vue@3.5.13(typescript@5.7.3))
'@vueuse/integrations': 13.1.0(axios@1.7.9)(fuse.js@7.1.0)(vue@3.5.13(typescript@5.7.3)) '@vueuse/integrations': 13.1.0(axios@1.7.9)(fuse.js@7.1.0)(vue@3.5.13(typescript@5.7.3))
colortranslator: 4.1.0 colortranslator: 4.1.0
consola: 3.4.2 consola: 3.4.2
@@ -7072,7 +7088,7 @@ snapshots:
tinyglobby: 0.2.13 tinyglobby: 0.2.13
typescript: 5.7.3 typescript: 5.7.3
unplugin: 2.3.2 unplugin: 2.3.2
unplugin-auto-import: 19.1.2(@nuxt/kit@3.17.2(magicast@0.3.5))(@vueuse/core@13.1.0(vue@3.5.13(typescript@5.7.3))) unplugin-auto-import: 19.1.2(@nuxt/kit@3.17.2(magicast@0.3.5))(@vueuse/core@13.6.0(vue@3.5.13(typescript@5.7.3)))
unplugin-vue-components: 28.5.0(@babel/parser@7.28.0)(@nuxt/kit@3.17.2(magicast@0.3.5))(vue@3.5.13(typescript@5.7.3)) unplugin-vue-components: 28.5.0(@babel/parser@7.28.0)(@nuxt/kit@3.17.2(magicast@0.3.5))(vue@3.5.13(typescript@5.7.3))
vaul-vue: 0.4.1(reka-ui@2.2.1(typescript@5.7.3)(vue@3.5.13(typescript@5.7.3)))(vue@3.5.13(typescript@5.7.3)) vaul-vue: 0.4.1(reka-ui@2.2.1(typescript@5.7.3)(vue@3.5.13(typescript@5.7.3)))(vue@3.5.13(typescript@5.7.3))
vue-component-type-helpers: 2.2.10 vue-component-type-helpers: 2.2.10
@@ -8201,6 +8217,13 @@ snapshots:
'@vueuse/shared': 13.1.0(vue@3.5.13(typescript@5.7.3)) '@vueuse/shared': 13.1.0(vue@3.5.13(typescript@5.7.3))
vue: 3.5.13(typescript@5.7.3) vue: 3.5.13(typescript@5.7.3)
'@vueuse/core@13.6.0(vue@3.5.13(typescript@5.7.3))':
dependencies:
'@types/web-bluetooth': 0.0.21
'@vueuse/metadata': 13.6.0
'@vueuse/shared': 13.6.0(vue@3.5.13(typescript@5.7.3))
vue: 3.5.13(typescript@5.7.3)
'@vueuse/integrations@13.1.0(axios@1.7.9)(fuse.js@7.1.0)(vue@3.5.13(typescript@5.7.3))': '@vueuse/integrations@13.1.0(axios@1.7.9)(fuse.js@7.1.0)(vue@3.5.13(typescript@5.7.3))':
dependencies: dependencies:
'@vueuse/core': 13.1.0(vue@3.5.13(typescript@5.7.3)) '@vueuse/core': 13.1.0(vue@3.5.13(typescript@5.7.3))
@@ -8216,6 +8239,8 @@ snapshots:
'@vueuse/metadata@13.1.0': {} '@vueuse/metadata@13.1.0': {}
'@vueuse/metadata@13.6.0': {}
'@vueuse/shared@10.11.1(vue@3.5.13(typescript@5.7.3))': '@vueuse/shared@10.11.1(vue@3.5.13(typescript@5.7.3))':
dependencies: dependencies:
vue-demi: 0.14.10(vue@3.5.13(typescript@5.7.3)) vue-demi: 0.14.10(vue@3.5.13(typescript@5.7.3))
@@ -8233,6 +8258,10 @@ snapshots:
dependencies: dependencies:
vue: 3.5.13(typescript@5.7.3) vue: 3.5.13(typescript@5.7.3)
'@vueuse/shared@13.6.0(vue@3.5.13(typescript@5.7.3))':
dependencies:
vue: 3.5.13(typescript@5.7.3)
abbrev@3.0.0: {} abbrev@3.0.0: {}
abort-controller@3.0.0: abort-controller@3.0.0:
@@ -11626,7 +11655,7 @@ snapshots:
universalify@2.0.1: {} universalify@2.0.1: {}
unplugin-auto-import@19.1.2(@nuxt/kit@3.17.2(magicast@0.3.5))(@vueuse/core@13.1.0(vue@3.5.13(typescript@5.7.3))): unplugin-auto-import@19.1.2(@nuxt/kit@3.17.2(magicast@0.3.5))(@vueuse/core@13.6.0(vue@3.5.13(typescript@5.7.3))):
dependencies: dependencies:
local-pkg: 1.1.1 local-pkg: 1.1.1
magic-string: 0.30.17 magic-string: 0.30.17
@@ -11636,7 +11665,7 @@ snapshots:
unplugin-utils: 0.2.4 unplugin-utils: 0.2.4
optionalDependencies: optionalDependencies:
'@nuxt/kit': 3.17.2(magicast@0.3.5) '@nuxt/kit': 3.17.2(magicast@0.3.5)
'@vueuse/core': 13.1.0(vue@3.5.13(typescript@5.7.3)) '@vueuse/core': 13.6.0(vue@3.5.13(typescript@5.7.3))
unplugin-utils@0.2.4: unplugin-utils@0.2.4:
dependencies: dependencies:

View File

@@ -55,8 +55,9 @@ export const auth = betterAuth({
const roleDisplayName = roleDisplayNames[data.role as LegalRole] || data.role const roleDisplayName = roleDisplayNames[data.role as LegalRole] || data.role
await resend.emails.send({ try {
from: 'Legal Consent Hub <noreply@legalconsenthub.com>', const result = await resend.emails.send({
from: 'Acme <onboarding@resend.dev>',
to: data.email, to: data.email,
subject: `Einladung als ${roleDisplayName} - ${data.organization.name}`, subject: `Einladung als ${roleDisplayName} - ${data.organization.name}`,
html: ` html: `
@@ -66,6 +67,27 @@ export const auth = betterAuth({
<p>Diese Einladung läuft ab am: ${new Date(data.invitation.expiresAt).toLocaleDateString('de-DE')}</p> <p>Diese Einladung läuft ab am: ${new Date(data.invitation.expiresAt).toLocaleDateString('de-DE')}</p>
` `
}) })
if (result.error) {
throw new Error(`Email sending failed: ${result.error?.statusCode} ${result.error?.error}`)
}
console.log('Email invite link:', inviteLink)
console.log('Invitation email sent successfully to:', data.email, 'with ID:', result.data?.id)
} catch (error) {
console.error('Failed to send invitation email:', error)
// Log specific error details for debugging
const errorObj = error as { response?: { status: number; data: unknown }; message?: string }
if (errorObj.response) {
console.error('HTTP Status:', errorObj.response.status)
console.error('Response data:', errorObj.response.data)
}
// Re-throw the error so BetterAuth knows the email failed
const message = errorObj.message || String(error)
throw new Error(`Email sending failed: ${message}`)
}
} }
}) })
] ]

View File

@@ -1,55 +1,87 @@
import { createAccessControl } from 'better-auth/plugins/access' import { createAccessControl } from 'better-auth/plugins/access'
import { defaultStatements, adminAc, memberAc, ownerAc } from 'better-auth/plugins/organization/access'
import { defu } from 'defu'
export const statement = { const customStatements = {
application_form: ['create', 'read', 'update', 'delete', 'approve', 'reject', 'submit'], application_form: ['create', 'read', 'update', 'delete', 'approve', 'reject', 'submit'],
agreement: ['create', 'read', 'update', 'sign', 'approve', 'reject'], agreement: ['create', 'read', 'update', 'sign', 'approve', 'reject'],
organization: ['create', 'read', 'update', 'delete', 'manage_settings'],
member: ['create', 'read', 'update', 'delete', 'invite', 'remove'],
comment: ['create', 'read', 'update', 'delete'], comment: ['create', 'read', 'update', 'delete'],
document: ['create', 'read', 'update', 'delete', 'download', 'upload'] document: ['create', 'read', 'update', 'delete', 'download', 'upload']
} as const } as const
export const statement = {
...customStatements,
...defaultStatements
} as const
export const accessControl = createAccessControl(statement) export const accessControl = createAccessControl(statement)
// Roles with specific permissions export const employerRole = accessControl.newRole(
export const employerRole = accessControl.newRole({ defu(
{
application_form: ['create', 'read', 'approve', 'reject'], application_form: ['create', 'read', 'approve', 'reject'],
agreement: ['create', 'read', 'sign', 'approve'], agreement: ['create', 'read', 'sign', 'approve'],
member: ['invite', 'read'],
comment: ['create', 'read', 'update', 'delete'], comment: ['create', 'read', 'update', 'delete'],
document: ['create', 'read', 'update', 'delete', 'download', 'upload'] document: ['create', 'read', 'update', 'delete', 'download', 'upload']
}) },
memberAc.statements
) as Parameters<typeof accessControl.newRole>[0]
)
export const worksCouncilMemberRole = accessControl.newRole({ export const worksCouncilMemberRole = accessControl.newRole(
defu(
{
application_form: ['create', 'read', 'update', 'submit'], application_form: ['create', 'read', 'update', 'submit'],
agreement: ['read', 'sign', 'approve'], agreement: ['read', 'sign', 'approve'],
member: ['read'],
comment: ['create', 'read', 'update', 'delete'], comment: ['create', 'read', 'update', 'delete'],
document: ['create', 'read', 'update', 'download', 'upload'] document: ['create', 'read', 'update', 'download', 'upload']
}) },
memberAc.statements
) as Parameters<typeof accessControl.newRole>[0]
)
export const employeeRole = accessControl.newRole({ export const employeeRole = accessControl.newRole(
defu(
{
application_form: ['read'], application_form: ['read'],
agreement: ['read'], agreement: ['read'],
member: ['read'],
comment: ['create', 'read'], comment: ['create', 'read'],
document: ['read', 'download'] document: ['read', 'download']
}) },
memberAc.statements
) as Parameters<typeof accessControl.newRole>[0]
)
export const adminRole = accessControl.newRole({ export const adminRole = accessControl.newRole(
defu(
{
application_form: ['create', 'read', 'update', 'delete', 'approve', 'reject'], application_form: ['create', 'read', 'update', 'delete', 'approve', 'reject'],
agreement: ['create', 'read', 'update', 'sign', 'approve', 'reject'], agreement: ['create', 'read', 'update', 'sign', 'approve', 'reject'],
organization: ['create', 'read', 'update', 'delete', 'manage_settings'],
member: ['create', 'read', 'update', 'delete', 'invite', 'remove'],
comment: ['create', 'read', 'update', 'delete'], comment: ['create', 'read', 'update', 'delete'],
document: ['create', 'read', 'update', 'delete', 'download', 'upload'] document: ['create', 'read', 'update', 'delete', 'download', 'upload']
}) },
adminAc.statements
) as Parameters<typeof accessControl.newRole>[0]
)
export const ownerRole = accessControl.newRole(
defu(
{
application_form: ['create', 'read', 'update', 'delete', 'approve', 'reject', 'submit'],
agreement: ['create', 'read', 'update', 'sign', 'approve', 'reject'],
comment: ['create', 'read', 'update', 'delete'],
document: ['create', 'read', 'update', 'delete', 'download', 'upload']
},
ownerAc.statements
) as Parameters<typeof accessControl.newRole>[0]
)
export const ROLES = { export const ROLES = {
EMPLOYER: 'employer', EMPLOYER: 'employer',
WORKS_COUNCIL_MEMBER: 'works_council_member', WORKS_COUNCIL_MEMBER: 'works_council_member',
EMPLOYEE: 'employee', EMPLOYEE: 'employee',
ADMIN: 'admin' ADMIN: 'admin',
OWNER: 'owner'
} as const } as const
export type LegalRole = (typeof ROLES)[keyof typeof ROLES] export type LegalRole = (typeof ROLES)[keyof typeof ROLES]

View File

@@ -1,8 +1,6 @@
{ {
"isTemplate": true, "isTemplate": true,
"name": "", "name": "",
"createdBy": "Denis",
"lastModifiedBy": "Denis",
"formElementSections": [ "formElementSections": [
{ {
"title": "Section 1", "title": "Section 1",