major: Rename legalconsenthub to gremiumhub
All checks were successful
CI/CD Pipeline / frontend (push) Successful in 5m52s
CI/CD Pipeline / backend (push) Successful in 7m58s
CI/CD Pipeline / deploy (push) Successful in 1s

This commit is contained in:
2026-03-16 10:28:32 +01:00
parent 52fe6b6392
commit afec157b35
326 changed files with 566 additions and 1004 deletions

View File

@@ -1,15 +0,0 @@
.git
.gitignore
README.md
*.md
.gradle
build
bin
!gradle/wrapper
postgres-data
docker-compose.yaml
.idea
.vscode
*.log
*.iml

View File

@@ -1,29 +0,0 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
max_line_length = 120
tab_width = 4
trim_trailing_whitespace = true
[*.{kt,kts}]
indent_size = 4
max_line_length = 120
ij_kotlin_allow_trailing_comma = true
ij_kotlin_allow_trailing_comma_on_call_site = true
ktlint_standard_package-name = disabled
[*.{yaml,yml}]
indent_size = 2
[*.gradle]
indent_size = 4
[*.md]
trim_trailing_whitespace = false

View File

@@ -1,3 +0,0 @@
/gradlew text eol=lf
*.bat text eol=crlf
*.jar binary

View File

@@ -1,125 +0,0 @@
# Backend - AI Context
## Tech Stack
- **Framework**: Spring Boot 3.4.2
- **Language**: Kotlin 1.9.25, Java 21
- **Database**: PostgreSQL
- **Migrations**: Liquibase (manual)
- **PDF Generation**: LaTeX (lualatex) via Thymeleaf templates
- **API**: Auto-generated server stubs from OpenAPI spec
---
## Development
```bash
# Run backend (port 8080)
./gradlew bootRun
# Build
./gradlew build
# Generate server stubs (after OpenAPI spec changes)
./gradlew generate_legalconsenthub_server
# Database migrations
# Never create Liquibase changesets in src/main/resources/db/changelog/
```
---
## Seed Data Maintenance
The application automatically seeds initial data on first startup. Seed files are split into smaller section files for easier maintenance.
### File Structure
```
src/main/resources/seed/
├── template/ # Form template (isTemplate=true)
│ ├── _main.yaml # Main file with metadata and !include directives
│ ├── section_01_*.yaml # Individual section files
│ └── ...
├── demo/ # Demo form (isTemplate=false)
│ ├── _main.yaml
│ ├── section_01_*.yaml
│ └── ...
```
### !include Directive
The `_main.yaml` files use `!include` directives to reference section files:
```yaml
formElementSections:
- !include section_01_angaben_zum_itsystem.yaml
- !include section_02_modulbeschreibung.yaml
```
`SplitYamlLoader` merges these files before deserialization.
### 1. Template Seeding
**Seeder:** `InitialApplicationFormTemplateSeeder`
**Directory:** `src/main/resources/seed/template/`
**Condition:** Seeds if no templates exist (`isTemplate = true`)
**Purpose:** Comprehensive IT system approval workflow template (16 sections)
**IMPORTANT:** Keep section files updated when form structure or validation rules change. After any change, also update the flow diagram at `docs/form-flow-diagram.md`.
### 2. Application Form Seeding
**Seeder:** `InitialApplicationFormSeeder`
**Directory:** `src/main/resources/seed/demo/`
**Condition:** Seeds if no forms exist for empty organizationId (`isTemplate = false`)
**Purpose:** Realistic SAP S/4HANA application form for development and UI testing (11 sections)
**organizationId:** Empty string (global form visible to all organizations)
**IMPORTANT:** Keep demo form synchronized with template changes. When modifying a template section, update the corresponding demo section.
**Note:**
- Forms with empty/null organizationId act as "global" forms and are visible to all organizations
- The demo form demonstrates visibility conditions, section spawning, clonable elements, and GDPR compliance features
---
## Key Files
| File | Purpose |
|------|---------|
| `src/main/resources/templates/application_form_latex_template.tex` | PDF template |
| `src/main/resources/seed/template/` | Form template sections (16 files) |
| `src/main/resources/seed/demo/` | Demo form sections (11 files) |
| `src/main/kotlin/.../seed/SplitYamlLoader.kt` | YAML merger for !include directives |
| `src/main/resources/db/changelog/` | Liquibase migrations |
| `src/main/kotlin/com/legalconsenthub/service/ApplicationFormFormatService.kt` | HTML/PDF export logic |
| `docs/form-flow-diagram.md` | Visual form flow diagram (update after template changes) |
---
## Rules for AI
1. **Never add SQL migrations** - They will be handled by the user
2. **Use mapper classes** - All DTO ↔ Entity conversions must happen in dedicated mapper classes (never in services/controllers)
- **CRITICAL: Entity + Mapper must be updated together** — When a new field is added to an OpenAPI DTO, you MUST update BOTH the entity class AND the mapper. A field present in the DTO but absent from the entity will silently return `null` from the API with no compile error. New entity fields that are persisted require a SQL migration (the user handles this).
- For JSON-typed fields (e.g. `GroupCondition`), use `@JdbcTypeCode(SqlTypes.JSON)` + `@Column(columnDefinition = "jsonb")`, same as `FormElement.visibilityConditions`.
3. **PDF Export Validation** - After changes to form elements, visibility, or spawning:
- Test both HTML and PDF export for real form instances
- Verify all element types render correctly
- Ensure template sections excluded, spawned sections included
- Hotspots: `ApplicationFormFormatService.kt`, `application_form_latex_template.tex`
4. **Stateless** - Any implementation must support statelessness for horizontal scaling (multiple instances behind load balancer)
### Architecture Guidelines
- **Service layer** - Business logic lives here
- **Repository layer** - JPA repositories for data access
- **Controller layer** - Thin controllers that delegate to services
- **Mapper layer** - Separate mapper classes for DTO/Entity conversions
- **Entity layer** - JPA entities with relationships
### Code Quality Rules
- **Never use @Suppress annotations** - Fix the underlying issue instead of suppressing warnings
- **Controller methods must override generated interfaces** - All public endpoints must be in OpenAPI spec
### PDF Generation
- LaTeX templates in `src/main/resources/templates/`
- Thymeleaf for dynamic content
- Use `lualatex` for compilation
- Escape special LaTeX characters in user input
- Test PDF generation after form structure changes

View File

@@ -1,50 +0,0 @@
FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /workspace/app
RUN mkdir -p ../api
COPY api/legalconsenthub.yml ../api/
COPY legalconsenthub-backend/gradlew .
COPY legalconsenthub-backend/gradle gradle
COPY legalconsenthub-backend/build.gradle .
COPY legalconsenthub-backend/settings.gradle .
RUN chmod +x ./gradlew
RUN ./gradlew dependencies --no-daemon
COPY legalconsenthub-backend/src src
RUN ./gradlew bootJar -x test --no-daemon
FROM eclipse-temurin:21-jre-alpine AS runner
WORKDIR /app
# Install TeXLive and LuaLaTeX
RUN apk add --no-cache \
texlive-luatex \
texmf-dist-plaingeneric \
texmf-dist-latexrecommended \
texmf-dist-latexextra \
texmf-dist-langgerman \
texmf-dist-fontsextra
RUN addgroup -S spring && adduser -S spring -G spring
# PDF cache directory must be writable by the non-root user (and ideally mounted as a volume in production)
RUN mkdir -p /var/lib/legalconsenthub/pdfs && chown -R spring:spring /var/lib/legalconsenthub
USER spring:spring
COPY --from=builder /workspace/app/build/libs/*.jar app.jar
ENV SPRING_PROFILES_ACTIVE=prod
ENV JAVA_OPTS="-Xms256m -Xmx512m"
ENV LEGALCONSENTHUB_PDF_STORAGE_FILESYSTEM_BASE_DIR=/var/lib/legalconsenthub/pdfs
EXPOSE 8080
ENTRYPOINT ["sh", "-c", "java ${JAVA_OPTS} -jar /app/app.jar"]

View File

@@ -1,5 +0,0 @@
# Legal Consent Hub Backend
## Pipeline Triggering
Trigger count: 14

View File

@@ -1,100 +0,0 @@
plugins {
id 'org.jetbrains.kotlin.jvm' version '1.9.25'
id 'org.jetbrains.kotlin.plugin.spring' version '1.9.25'
id 'org.springframework.boot' version '3.4.2'
id 'io.spring.dependency-management' version '1.1.7'
id 'org.jetbrains.kotlin.plugin.jpa' version '1.9.25'
id 'org.openapi.generator' version '7.11.0'
id 'org.jlleitschuh.gradle.ktlint' version '13.1.0'
}
group = 'com.betriebsratkanzlei'
version = '0.0.1-SNAPSHOT'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin'
implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-yaml'
implementation 'org.jetbrains.kotlin:kotlin-reflect'
implementation 'org.liquibase:liquibase-core'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.6'
implementation 'org.springdoc:springdoc-openapi-ui:1.8.0'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
// https://docs.spring.io/spring-security/reference/servlet/oauth2/index.html#oauth2-resource-server-access-token-jwt
implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'org.springframework.boot:spring-boot-starter-mail'
implementation 'org.apache.tika:tika-core:2.9.1'
runtimeOnly 'com.h2database:h2'
implementation 'org.postgresql:postgresql'
implementation 'org.springframework.boot:spring-boot-testcontainers'
implementation 'org.testcontainers:postgresql'
testImplementation 'org.jetbrains.kotlin:kotlin-test-junit5'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
kotlin {
compilerOptions {
freeCompilerArgs.addAll '-Xjsr305=strict'
}
}
allOpen {
annotation 'jakarta.persistence.Entity'
annotation 'jakarta.persistence.MappedSuperclass'
annotation 'jakarta.persistence.Embeddable'
}
ktlint {
version = "1.5.0"
android = false
ignoreFailures = false
filter {
exclude("**/generated/**")
exclude { element -> element.file.path.contains("generated/") }
}
}
def generatedSourcesServerLegalconsenthubDir = "$buildDir/generated/server".toString()
sourceSets {
main {
kotlin.srcDirs += generatedSourcesServerLegalconsenthubDir + '/src/main/kotlin'
}
}
tasks.named('test') {
useJUnitPlatform()
}
task generate_legalconsenthub_server(type: org.openapitools.generator.gradle.plugin.tasks.GenerateTask) {
generatorName = 'kotlin-spring'
inputSpec = "$rootDir/../api/legalconsenthub.yml".toString()
outputDir = generatedSourcesServerLegalconsenthubDir
apiPackage = 'com.betriebsratkanzlei.legalconsenthub_api.api'
modelPackage = 'com.betriebsratkanzlei.legalconsenthub_api.model'
groupId = 'com.betriebsratkanzlei'
id = 'legalconsenthub_api'
configOptions = [useTags : 'true',
enumPropertyNaming: 'original',
interfaceOnly : 'true',
useSpringBoot3 : 'true']
typeMappings = [DateTime: "Instant"]
importMappings = [Instant: "java.time.Instant"]
}
compileKotlin.dependsOn(tasks.generate_legalconsenthub_server)
runKtlintCheckOverMainSourceSet.dependsOn(generate_legalconsenthub_server)

View File

@@ -1,7 +0,0 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.12.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

View File

@@ -1,252 +0,0 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

View File

@@ -1,94 +0,0 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@@ -1 +0,0 @@
rootProject.name = 'legalconsenthub'

View File

@@ -1,17 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.data.jpa.repository.config.EnableJpaAuditing
import org.springframework.scheduling.annotation.EnableAsync
import org.springframework.scheduling.annotation.EnableScheduling
@SpringBootApplication
@EnableJpaAuditing
@EnableAsync
@EnableScheduling
class LegalconsenthubApplication
fun main(args: Array<String>) {
runApplication<LegalconsenthubApplication>(*args)
}

View File

@@ -1,54 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.application_form
import com.betriebsratkanzlei.legalconsenthub.application_form_version.ApplicationFormVersion
import com.betriebsratkanzlei.legalconsenthub.form_element.FormElementSection
import com.betriebsratkanzlei.legalconsenthub.user.User
import com.betriebsratkanzlei.legalconsenthub_api.model.ApplicationFormStatus
import jakarta.persistence.CascadeType
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.EntityListeners
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
import jakarta.persistence.GeneratedValue
import jakarta.persistence.Id
import jakarta.persistence.JoinColumn
import jakarta.persistence.ManyToOne
import jakarta.persistence.OneToMany
import org.springframework.data.annotation.CreatedDate
import org.springframework.data.annotation.LastModifiedDate
import org.springframework.data.jpa.domain.support.AuditingEntityListener
import java.time.Instant
import java.util.UUID
@Entity
@EntityListeners(AuditingEntityListener::class)
class ApplicationForm(
@Id
@GeneratedValue
var id: UUID? = null,
@Column(nullable = false)
var name: String = "",
@OneToMany(mappedBy = "applicationForm", cascade = [CascadeType.ALL], orphanRemoval = true)
var formElementSections: MutableList<FormElementSection> = mutableListOf(),
@OneToMany(mappedBy = "applicationForm", cascade = [CascadeType.ALL])
var versions: MutableList<ApplicationFormVersion> = mutableListOf(),
@Column(nullable = false)
var isTemplate: Boolean,
var organizationId: String = "",
@Enumerated(EnumType.STRING)
@Column(nullable = false)
var status: ApplicationFormStatus = ApplicationFormStatus.DRAFT,
@ManyToOne
@JoinColumn(name = "created_by_id", nullable = false)
var createdBy: User,
@ManyToOne
@JoinColumn(name = "last_modified_by_id", nullable = false)
var lastModifiedBy: User,
@CreatedDate
@Column(nullable = false)
var createdAt: Instant? = null,
@LastModifiedDate
@Column(nullable = false)
var modifiedAt: Instant? = null,
)

View File

@@ -1,104 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.application_form
import com.betriebsratkanzlei.legalconsenthub_api.api.ApplicationFormApi
import com.betriebsratkanzlei.legalconsenthub_api.model.ApplicationFormDto
import com.betriebsratkanzlei.legalconsenthub_api.model.FormElementDto
import com.betriebsratkanzlei.legalconsenthub_api.model.PagedApplicationFormDto
import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.RestController
import java.util.UUID
@RestController
class ApplicationFormController(
val applicationFormService: ApplicationFormService,
val pagedApplicationFormMapper: PagedApplicationFormMapper,
val applicationFormMapper: ApplicationFormMapper,
val applicationFormFormatService: ApplicationFormFormatService,
) : ApplicationFormApi {
@PreAuthorize(
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL')",
)
override fun createApplicationForm(applicationFormDto: ApplicationFormDto): ResponseEntity<ApplicationFormDto> {
val updatedApplicationFormDto = applicationFormDto.copy(isTemplate = false)
return ResponseEntity.ok(
applicationFormMapper.toApplicationFormDto(
applicationFormService.createApplicationForm(updatedApplicationFormDto),
),
)
}
@PreAuthorize(
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL', 'EMPLOYEE')",
)
override fun getAllApplicationForms(organizationId: String?): ResponseEntity<PagedApplicationFormDto> =
ResponseEntity.ok(
applicationFormService.getApplicationFormsWithCommentCounts(organizationId).let { result ->
pagedApplicationFormMapper.toPagedApplicationFormDto(
result.page,
result.commentCountByApplicationFormId,
)
},
)
@PreAuthorize(
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL', 'EMPLOYEE')",
)
override fun getApplicationFormById(id: UUID): ResponseEntity<ApplicationFormDto> =
ResponseEntity.ok(
applicationFormMapper.toApplicationFormDto(
applicationFormService.getApplicationFormById(id),
),
)
@PreAuthorize(
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL')",
)
override fun updateApplicationForm(
id: UUID,
applicationFormDto: ApplicationFormDto,
): ResponseEntity<ApplicationFormDto> =
ResponseEntity.ok(
applicationFormMapper.toApplicationFormDto(
applicationFormService.updateApplicationForm(id, applicationFormDto),
),
)
@PreAuthorize(
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL')",
)
override fun deleteApplicationForm(id: UUID): ResponseEntity<Unit> {
applicationFormService.deleteApplicationFormByID(id)
return ResponseEntity.noContent().build()
}
@PreAuthorize(
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL')",
)
override fun submitApplicationForm(id: UUID): ResponseEntity<ApplicationFormDto> =
ResponseEntity.ok(
applicationFormMapper.toApplicationFormDto(
applicationFormService.submitApplicationForm(id),
),
)
@PreAuthorize(
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL')",
)
override fun addFormElementToSubSection(
applicationFormId: UUID,
subsectionId: UUID,
position: Int,
formElementDto: FormElementDto,
): ResponseEntity<ApplicationFormDto> =
ResponseEntity.status(201).body(
applicationFormMapper.toApplicationFormDto(
applicationFormService.addFormElementToSubSection(
applicationFormId,
subsectionId,
formElementDto,
position,
),
),
)
}

View File

@@ -1,358 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.application_form
import com.betriebsratkanzlei.legalconsenthub.application_form.export.latex.LatexEscaper
import com.betriebsratkanzlei.legalconsenthub.application_form.export.latex.LatexExportModel
import com.betriebsratkanzlei.legalconsenthub.application_form.export.latex.LatexFormElement
import com.betriebsratkanzlei.legalconsenthub.application_form.export.latex.LatexPdfRenderer
import com.betriebsratkanzlei.legalconsenthub.application_form.export.latex.LatexSection
import com.betriebsratkanzlei.legalconsenthub.application_form.export.latex.LatexSubSection
import com.betriebsratkanzlei.legalconsenthub.application_form.export.latex.RichTextToLatexConverter
import com.betriebsratkanzlei.legalconsenthub_api.model.ApplicationFormSnapshotDto
import com.betriebsratkanzlei.legalconsenthub_api.model.FormElementSectionSnapshotDto
import com.betriebsratkanzlei.legalconsenthub_api.model.FormElementSnapshotDto
import com.betriebsratkanzlei.legalconsenthub_api.model.FormElementSubSectionSnapshotDto
import com.fasterxml.jackson.core.type.TypeReference
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import org.springframework.stereotype.Service
import org.thymeleaf.TemplateEngine
import org.thymeleaf.context.Context
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import com.betriebsratkanzlei.legalconsenthub_api.model.VisibilityConditionGroup as VisibilityConditionGroupDto
import com.betriebsratkanzlei.legalconsenthub_api.model.VisibilityConditionNode as VisibilityConditionNodeDto
import com.betriebsratkanzlei.legalconsenthub_api.model.VisibilityConditionOperator as VisibilityConditionOperatorDto
import com.betriebsratkanzlei.legalconsenthub_api.model.VisibilityConditionType as VisibilityConditionTypeDto
@Service
class ApplicationFormFormatService(
private val templateEngine: TemplateEngine,
private val richTextToLatexConverter: RichTextToLatexConverter,
private val pdfRenderer: LatexPdfRenderer,
) {
fun generatePdf(
snapshot: ApplicationFormSnapshotDto,
createdAt: Instant?,
): ByteArray {
val latexContent = generateLatex(snapshot, createdAt)
return pdfRenderer.render(latexContent)
}
fun generateLatex(
snapshot: ApplicationFormSnapshotDto,
createdAt: Instant?,
): String {
val filteredSnapshot = filterVisibleElements(snapshot)
val exportModel = buildLatexExportModel(filteredSnapshot, createdAt)
val context =
Context().apply {
setVariable("applicationForm", exportModel)
}
return templateEngine.process("application_form_latex_template", context)
}
private fun buildLatexExportModel(
snapshot: ApplicationFormSnapshotDto,
createdAt: Instant?,
): LatexExportModel {
val dateFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy")
return LatexExportModel(
id = null,
name = LatexEscaper.escape(snapshot.name),
organizationId = LatexEscaper.escape(snapshot.organizationId),
employer = LatexEscaper.escape("Arbeitgeber der Organisation ${snapshot.organizationId}"),
worksCouncil = LatexEscaper.escape("Betriebsrat der Organisation ${snapshot.organizationId}"),
createdAt = createdAt?.atZone(ZoneId.of("Europe/Berlin"))?.format(dateFormatter) ?: "",
sections =
snapshot.sections.map { section ->
LatexSection(
title = LatexEscaper.escape(section.title),
description = LatexEscaper.escape(section.description ?: ""),
subsections =
section.subsections.map { subsection ->
LatexSubSection(
title = LatexEscaper.escape(subsection.title),
subtitle = LatexEscaper.escape(subsection.subtitle ?: ""),
elements =
subsection.elements.map { element ->
val isTable = element.type.name == "TABLE"
val tableInfo = if (isTable) renderTableValue(element) else null
LatexFormElement(
title = LatexEscaper.escape(element.title ?: ""),
description = LatexEscaper.escape(element.description ?: ""),
value = tableInfo?.first ?: renderElementValue(element),
isTable = isTable,
isWideTable = tableInfo?.second ?: false,
)
},
)
},
)
},
)
}
private fun renderElementValue(element: FormElementSnapshotDto): String =
when (element.type.name) {
"TEXTFIELD", "TEXTAREA" -> {
val value = element.options.firstOrNull()?.value
if (value.isNullOrBlank()) "Keine Eingabe" else LatexEscaper.escape(value)
}
"DATE" -> {
val value = element.options.firstOrNull()?.value
if (value.isNullOrBlank()) {
"Kein Datum ausgewählt"
} else {
try {
LocalDate.parse(value).format(DateTimeFormatter.ofPattern("dd.MM.yyyy"))
} catch (e: Exception) {
LatexEscaper.escape(value)
}
}
}
"RICH_TEXT" -> {
val value = element.options.firstOrNull()?.value
if (value.isNullOrBlank()) "Keine Eingabe" else richTextToLatexConverter.convertToLatex(value)
}
"SELECT", "CHECKBOX" -> {
val selected = element.options.filter { it.value == "true" }.map { it.label }
if (selected.isEmpty()) {
"Keine Auswahl getroffen"
} else {
selected.joinToString(
", ",
) { LatexEscaper.escape(it) }
}
}
"RADIOBUTTON" -> {
val selected = element.options.firstOrNull { it.value == "true" }?.label
if (selected == null) "Keine Auswahl getroffen" else LatexEscaper.escape(selected)
}
"SWITCH" -> {
if (element.options.any { it.value == "true" }) "Ja" else "Nein"
}
"FILE_UPLOAD" -> {
val files =
element.options.mapNotNull { option ->
val value = option.value
if (value.isBlank()) {
null
} else {
try {
val metadata = jacksonObjectMapper().readValue(value, Map::class.java)
metadata["filename"] as? String
} catch (_: Exception) {
null
}
}
}
if (files.isEmpty()) {
"Keine Dateien hochgeladen"
} else {
files.joinToString("\\\\") { LatexEscaper.escape(it) }
}
}
"TABLE" -> {
renderTableValue(element).first
}
else -> "Keine Auswahl getroffen"
}
/**
* Renders a table element to LaTeX.
* @return Pair of (LaTeX string, isWideTable flag)
*/
private fun renderTableValue(element: FormElementSnapshotDto): Pair<String, Boolean> {
if (element.options.isEmpty()) return "Keine Daten" to false
val objectMapper = jacksonObjectMapper()
val headers = element.options.map { LatexEscaper.escape(it.label) }
val columnData =
element.options.map { option ->
try {
val typeRef = object : TypeReference<List<String>>() {}
objectMapper.readValue(option.value, typeRef)
} catch (e: Exception) {
emptyList<String>()
}
}
val rowCount = columnData.maxOfOrNull { col -> col.size } ?: 0
if (rowCount == 0) return "Keine Daten" to false
val columnCount = headers.size
val isWideTable = columnCount > WIDE_TABLE_COLUMN_THRESHOLD
// Use tabularx with Y columns (auto-wrapping) for flexible width distribution
// Y is defined as >{\raggedright\arraybackslash}X in the template
// For wide tables, use \linewidth (works correctly inside landscape environment)
val tableWidth = if (isWideTable) "\\linewidth" else "\\textwidth"
val columnSpec = headers.joinToString("") { "Y" }
val headerRow = headers.joinToString(" & ") { "\\textbf{$it}" }
val dataRows =
(0 until rowCount).map { rowIndex ->
columnData.joinToString(" & ") { col: List<String> ->
val value = col.getOrNull(rowIndex) ?: ""
if (value.isBlank()) "-" else LatexEscaper.escape(value)
}
}
val latexContent =
buildString {
if (isWideTable) {
// Use smaller font and tighter column spacing for wide tables
appendLine("\\footnotesize")
appendLine("\\setlength{\\tabcolsep}{2pt}")
}
appendLine("\\begin{tabularx}{$tableWidth}{$columnSpec}")
appendLine("\\toprule")
appendLine("$headerRow \\\\")
appendLine("\\midrule")
dataRows.forEach { row: String ->
appendLine("$row \\\\")
}
appendLine("\\bottomrule")
appendLine("\\end{tabularx}")
if (isWideTable) {
// Reset to normal settings after the table
appendLine("\\normalsize")
appendLine("\\setlength{\\tabcolsep}{6pt}")
}
}
return latexContent to isWideTable
}
companion object {
/** Tables with more than this number of columns are rendered in landscape mode */
private const val WIDE_TABLE_COLUMN_THRESHOLD = 6
}
private fun filterVisibleElements(snapshot: ApplicationFormSnapshotDto): ApplicationFormSnapshotDto {
val allElements = collectAllFormElements(snapshot)
val formElementsByRef = buildSnapshotFormElementsByRefMap(allElements)
val filteredSections =
snapshot.sections
.filter { it.isTemplate != true }
.mapNotNull { section ->
val filteredSubsections =
section.subsections.mapNotNull { subsection ->
val filteredElements =
subsection.elements.filter { element ->
isElementVisible(element, formElementsByRef)
}
if (filteredElements.isEmpty()) null else subsection.copy(elements = filteredElements)
}
if (filteredSubsections.isEmpty()) null else section.copy(subsections = filteredSubsections)
}
return snapshot.copy(sections = filteredSections)
}
private fun collectAllFormElements(snapshot: ApplicationFormSnapshotDto): List<FormElementSnapshotDto> =
snapshot.sections
.flatMap(FormElementSectionSnapshotDto::subsections)
.flatMap(FormElementSubSectionSnapshotDto::elements)
private fun buildSnapshotFormElementsByRefMap(
allElements: List<FormElementSnapshotDto>,
): Map<String, FormElementSnapshotDto> =
allElements
.mapNotNull { elem -> elem.reference?.let { it to elem } }
.toMap()
private fun isElementVisible(
element: FormElementSnapshotDto,
formElementsByRef: Map<String, FormElementSnapshotDto>,
): Boolean {
val group = element.visibilityConditions ?: return true
if (group.conditions?.isEmpty() != false) return true
return evaluateGroup(group, formElementsByRef)
}
private fun evaluateGroup(
group: VisibilityConditionGroupDto,
formElementsByRef: Map<String, FormElementSnapshotDto>,
): Boolean {
val conditions = group.conditions ?: return true
val results = conditions.map { evaluateNode(it, formElementsByRef) }
return when (group.operator) {
VisibilityConditionGroupDto.Operator.AND -> results.all { it }
VisibilityConditionGroupDto.Operator.OR -> results.any { it }
null -> true
}
}
private fun evaluateNode(
node: VisibilityConditionNodeDto,
formElementsByRef: Map<String, FormElementSnapshotDto>,
): Boolean =
when (node.nodeType) {
VisibilityConditionNodeDto.NodeType.LEAF, null -> evaluateLeafCondition(node, formElementsByRef)
VisibilityConditionNodeDto.NodeType.GROUP -> {
val nestedGroup =
VisibilityConditionGroupDto(
operator =
when (node.groupOperator) {
VisibilityConditionNodeDto.GroupOperator.AND -> VisibilityConditionGroupDto.Operator.AND
VisibilityConditionNodeDto.GroupOperator.OR -> VisibilityConditionGroupDto.Operator.OR
null -> VisibilityConditionGroupDto.Operator.AND
},
conditions = node.conditions,
)
evaluateGroup(nestedGroup, formElementsByRef)
}
}
private fun evaluateLeafCondition(
node: VisibilityConditionNodeDto,
formElementsByRef: Map<String, FormElementSnapshotDto>,
): Boolean {
val sourceRef = node.sourceFormElementReference ?: return false
val sourceElement = formElementsByRef[sourceRef] ?: return false
val sourceValue = getFormElementValue(sourceElement)
val operator = node.formElementOperator ?: VisibilityConditionOperatorDto.EQUALS
val conditionMet = evaluateCondition(sourceValue, node.formElementExpectedValue ?: "", operator)
return when (node.formElementConditionType) {
VisibilityConditionTypeDto.SHOW -> conditionMet
VisibilityConditionTypeDto.HIDE -> !conditionMet
null -> conditionMet
}
}
private fun getFormElementValue(element: FormElementSnapshotDto): String =
when (element.type.name) {
"SELECT",
"RADIOBUTTON",
-> element.options.firstOrNull { it.value == "true" }?.label ?: ""
"CHECKBOX",
"SWITCH",
-> if (element.options.any { it.value == "true" }) "true" else "false"
else -> element.options.firstOrNull()?.value ?: ""
}
private fun evaluateCondition(
actualValue: String,
expectedValue: String?,
operator: VisibilityConditionOperatorDto,
): Boolean =
when (operator) {
VisibilityConditionOperatorDto.EQUALS ->
expectedValue?.let { actualValue.equals(it, ignoreCase = true) } ?: false
VisibilityConditionOperatorDto.NOT_EQUALS ->
expectedValue?.let { !actualValue.equals(it, ignoreCase = true) } ?: false
VisibilityConditionOperatorDto.IS_EMPTY -> actualValue.isEmpty()
VisibilityConditionOperatorDto.IS_NOT_EMPTY -> actualValue.isNotEmpty()
VisibilityConditionOperatorDto.CONTAINS ->
expectedValue?.let { actualValue.contains(it, ignoreCase = true) } ?: false
VisibilityConditionOperatorDto.NOT_CONTAINS ->
expectedValue?.let { !actualValue.contains(it, ignoreCase = true) } ?: true
}
}

View File

@@ -1,101 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.application_form
import com.betriebsratkanzlei.legalconsenthub.form_element.FormElementSectionMapper
import com.betriebsratkanzlei.legalconsenthub.user.User
import com.betriebsratkanzlei.legalconsenthub.user.UserMapper
import com.betriebsratkanzlei.legalconsenthub.user.UserService
import com.betriebsratkanzlei.legalconsenthub_api.model.ApplicationFormDto
import org.springframework.stereotype.Component
import java.time.Instant
import java.util.UUID
@Component
class ApplicationFormMapper(
private val formElementSectionMapper: FormElementSectionMapper,
private val userMapper: UserMapper,
private val userService: UserService,
) {
fun toApplicationFormDto(applicationForm: ApplicationForm): ApplicationFormDto =
toApplicationFormDto(applicationForm, null)
fun toApplicationFormDto(
applicationForm: ApplicationForm,
commentCount: Long?,
): ApplicationFormDto =
ApplicationFormDto(
id = applicationForm.id,
name = applicationForm.name,
formElementSections =
applicationForm.formElementSections.map {
formElementSectionMapper
.toFormElementSectionDto(
it,
)
},
isTemplate = applicationForm.isTemplate,
organizationId = applicationForm.organizationId,
createdBy = userMapper.toUserDto(applicationForm.createdBy),
lastModifiedBy = userMapper.toUserDto(applicationForm.lastModifiedBy),
createdAt = applicationForm.createdAt ?: Instant.now(),
modifiedAt = applicationForm.modifiedAt ?: Instant.now(),
status = applicationForm.status,
commentCount = commentCount,
)
fun toNewApplicationForm(applicationFormDto: ApplicationFormDto): ApplicationForm {
val currentUser = userService.getCurrentUser()
return toNewApplicationForm(applicationFormDto, currentUser)
}
fun toNewApplicationForm(
applicationFormDto: ApplicationFormDto,
user: User,
): ApplicationForm {
val applicationForm =
ApplicationForm(
id = null,
name = applicationFormDto.name,
isTemplate = applicationFormDto.isTemplate,
organizationId = applicationFormDto.organizationId ?: "",
status =
applicationFormDto.status
?: com.betriebsratkanzlei.legalconsenthub_api.model.ApplicationFormStatus.DRAFT,
createdBy = user,
lastModifiedBy = user,
)
applicationForm.formElementSections =
applicationFormDto.formElementSections
.map { formElementSectionMapper.toNewFormElementSection(it, applicationForm) }
.toMutableList()
return applicationForm
}
fun toUpdatedApplicationForm(
id: UUID,
applicationFormDto: ApplicationFormDto,
existingApplicationForm: ApplicationForm,
): ApplicationForm {
val currentUser = userService.getCurrentUser()
val form =
ApplicationForm(
id = id,
name = applicationFormDto.name,
isTemplate = applicationFormDto.isTemplate,
organizationId = applicationFormDto.organizationId ?: existingApplicationForm.organizationId,
status = applicationFormDto.status ?: existingApplicationForm.status,
createdBy = existingApplicationForm.createdBy,
lastModifiedBy = currentUser,
createdAt = existingApplicationForm.createdAt,
modifiedAt = existingApplicationForm.modifiedAt,
)
form.formElementSections =
applicationFormDto.formElementSections
.map { formElementSectionMapper.toFormElementSection(it, form) }
.toMutableList()
return form
}
}

View File

@@ -1,25 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.application_form
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.Query
import org.springframework.stereotype.Repository
import java.util.UUID
@Repository
interface ApplicationFormRepository : JpaRepository<ApplicationForm, UUID> {
fun findAllByIsTemplateTrue(page: Pageable): Page<ApplicationForm>
fun existsByIsTemplateTrue(): Boolean
fun existsByIsTemplateFalseAndOrganizationId(organizationId: String): Boolean
@Query(
"SELECT c FROM ApplicationForm c WHERE (c.isTemplate IS false) AND (:organizationId is null or c.organizationId = :organizationId or c.organizationId IS NULL or c.organizationId = '')",
)
fun findAllByIsTemplateFalseAndOrganizationId(
organizationId: String?,
page: Pageable,
): Page<ApplicationForm>
}

View File

@@ -1,276 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.application_form
import com.betriebsratkanzlei.legalconsenthub.application_form_version.ApplicationFormVersionService
import com.betriebsratkanzlei.legalconsenthub.comment.CommentRepository
import com.betriebsratkanzlei.legalconsenthub.email.ApplicationFormCreatedEvent
import com.betriebsratkanzlei.legalconsenthub.email.ApplicationFormSubmittedEvent
import com.betriebsratkanzlei.legalconsenthub.email.ApplicationFormUpdatedEvent
import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormInvalidStateException
import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotCreatedException
import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotDeletedException
import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotFoundException
import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotUpdatedException
import com.betriebsratkanzlei.legalconsenthub.file.FileService
import com.betriebsratkanzlei.legalconsenthub.form_element.FormElementMapper
import com.betriebsratkanzlei.legalconsenthub.notification.NotificationService
import com.betriebsratkanzlei.legalconsenthub.user.UserService
import com.betriebsratkanzlei.legalconsenthub_api.model.ApplicationFormDto
import com.betriebsratkanzlei.legalconsenthub_api.model.ApplicationFormStatus
import com.betriebsratkanzlei.legalconsenthub_api.model.CreateNotificationDto
import com.betriebsratkanzlei.legalconsenthub_api.model.FormElementDto
import com.betriebsratkanzlei.legalconsenthub_api.model.NotificationType
import org.springframework.context.ApplicationEventPublisher
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
data class ApplicationFormPageWithCommentCounts(
val page: Page<ApplicationForm>,
val commentCountByApplicationFormId: Map<UUID, Long>,
)
@Service
class ApplicationFormService(
private val applicationFormRepository: ApplicationFormRepository,
private val applicationFormMapper: ApplicationFormMapper,
private val formElementMapper: FormElementMapper,
private val notificationService: NotificationService,
private val versionService: ApplicationFormVersionService,
private val userService: UserService,
private val eventPublisher: ApplicationEventPublisher,
private val commentRepository: CommentRepository,
private val fileService: FileService,
) {
@Transactional(rollbackFor = [Exception::class])
fun createApplicationForm(applicationFormDto: ApplicationFormDto): ApplicationForm {
val applicationForm = applicationFormMapper.toNewApplicationForm(applicationFormDto)
val savedApplicationForm: ApplicationForm
try {
savedApplicationForm = applicationFormRepository.save(applicationForm)
} catch (e: Exception) {
throw ApplicationFormNotCreatedException(e)
}
// Associate files atomically if provided
val fileIds = applicationFormDto.fileIds
if (!fileIds.isNullOrEmpty()) {
fileService.associateTemporaryFilesTransactional(
fileIds,
savedApplicationForm,
)
}
val currentUser = userService.getCurrentUser()
versionService.createVersion(savedApplicationForm, currentUser)
eventPublisher.publishEvent(
ApplicationFormCreatedEvent(
applicationFormId = savedApplicationForm.id!!,
organizationId = savedApplicationForm.organizationId,
creatorName = currentUser.name,
formName = savedApplicationForm.name,
),
)
return savedApplicationForm
}
fun getApplicationFormById(id: UUID): ApplicationForm =
applicationFormRepository.findById(id).orElseThrow {
ApplicationFormNotFoundException(id)
}
fun getApplicationForms(organizationId: String?): Page<ApplicationForm> {
val pageable = PageRequest.of(0, 100)
return applicationFormRepository.findAllByIsTemplateFalseAndOrganizationId(organizationId, pageable)
}
fun getApplicationFormsWithCommentCounts(organizationId: String?): ApplicationFormPageWithCommentCounts {
val page = getApplicationForms(organizationId)
val applicationFormIds = page.content.mapNotNull { it.id }
if (applicationFormIds.isEmpty()) {
return ApplicationFormPageWithCommentCounts(page, emptyMap())
}
val counts =
commentRepository.countByApplicationFormIds(applicationFormIds).associate { projection ->
projection.applicationFormId to projection.commentCount
}
return ApplicationFormPageWithCommentCounts(page, counts)
}
fun updateApplicationForm(
id: UUID,
applicationFormDto: ApplicationFormDto,
): ApplicationForm {
val existingApplicationForm = getApplicationFormById(id)
val existingSnapshot = versionService.createSnapshot(existingApplicationForm)
val applicationForm =
applicationFormMapper.toUpdatedApplicationForm(
id,
applicationFormDto,
existingApplicationForm,
)
val updatedApplicationForm: ApplicationForm
try {
updatedApplicationForm = applicationFormRepository.save(applicationForm)
} catch (e: Exception) {
throw ApplicationFormNotUpdatedException(e, id)
}
val currentUser = userService.getCurrentUser()
val newSnapshot = versionService.createSnapshot(updatedApplicationForm)
if (existingSnapshot != newSnapshot) {
versionService.createVersion(updatedApplicationForm, currentUser)
// Notify the form author if someone else made changes
if (updatedApplicationForm.createdBy.keycloakId != currentUser.keycloakId) {
createNotificationForFormUpdate(updatedApplicationForm, currentUser.name)
}
}
return updatedApplicationForm
}
fun deleteApplicationFormByID(id: UUID) {
try {
applicationFormRepository.deleteById(id)
} catch (e: Exception) {
throw ApplicationFormNotDeletedException(e)
}
}
fun submitApplicationForm(id: UUID): ApplicationForm {
val applicationForm = getApplicationFormById(id)
if (applicationForm.status != ApplicationFormStatus.DRAFT) {
throw ApplicationFormInvalidStateException(
applicationFormId = id,
currentState = applicationForm.status,
expectedState = ApplicationFormStatus.DRAFT,
operation = "submit",
)
}
applicationForm.status = ApplicationFormStatus.SUBMITTED
val savedApplicationForm =
try {
applicationFormRepository.save(applicationForm)
} catch (e: Exception) {
throw ApplicationFormNotUpdatedException(e, id)
}
val currentUser = userService.getCurrentUser()
versionService.createVersion(savedApplicationForm, currentUser)
createNotificationForOrganization(savedApplicationForm)
eventPublisher.publishEvent(
ApplicationFormSubmittedEvent(
applicationFormId = savedApplicationForm.id!!,
organizationId = savedApplicationForm.organizationId,
creatorName = currentUser.name,
formName = savedApplicationForm.name,
),
)
return savedApplicationForm
}
private fun createNotificationForOrganization(applicationForm: ApplicationForm) {
val title = "Neuer Mitbestimmungsantrag eingereicht"
val message =
"Ein neuer Mitbestimmungsantrag '${applicationForm.name}' wurde von " +
"${applicationForm.createdBy.name} eingereicht."
val clickTarget = "/application-forms/${applicationForm.id}/0"
val createNotificationDto =
CreateNotificationDto(
title = title,
message = message,
clickTarget = clickTarget,
recipientId = null,
targetRoles = null,
excludedUserId = applicationForm.createdBy.keycloakId,
type = NotificationType.INFO,
organizationId = applicationForm.organizationId,
)
notificationService.createNotificationForOrganization(createNotificationDto)
}
private fun createNotificationForFormUpdate(
applicationForm: ApplicationForm,
modifierName: String,
) {
val title = "Änderungen an Ihrem Mitbestimmungsantrag"
val message =
"$modifierName hat Änderungen an Ihrem Mitbestimmungsantrag '${applicationForm.name}' vorgenommen."
val clickTarget = "/application-forms/${applicationForm.id}/0"
val createNotificationDto =
CreateNotificationDto(
title = title,
message = message,
clickTarget = clickTarget,
recipientId = applicationForm.createdBy.keycloakId,
targetRoles = null,
excludedUserId = null,
type = NotificationType.INFO,
organizationId = applicationForm.organizationId,
)
notificationService.createNotificationForUser(createNotificationDto)
// Publish email event for form author
eventPublisher.publishEvent(
ApplicationFormUpdatedEvent(
applicationFormId = applicationForm.id!!,
organizationId = applicationForm.organizationId,
updaterName = modifierName,
formName = applicationForm.name,
authorKeycloakId = applicationForm.createdBy.keycloakId,
),
)
}
fun addFormElementToSubSection(
applicationFormId: UUID,
subsectionId: UUID,
formElementDto: FormElementDto,
position: Int,
): ApplicationForm {
val applicationForm = getApplicationFormById(applicationFormId)
val subsection =
applicationForm.formElementSections
.flatMap { it.formElementSubSections }
.find { it.id == subsectionId }
?: throw IllegalArgumentException("FormElementSubSection with id $subsectionId not found")
val newFormElement = formElementMapper.toNewFormElement(formElementDto, subsection)
if (position >= 0 && position < subsection.formElements.size) {
subsection.formElements.add(position, newFormElement)
} else {
subsection.formElements.add(newFormElement)
}
val updatedApplicationForm =
try {
applicationFormRepository.save(applicationForm)
} catch (e: Exception) {
throw ApplicationFormNotUpdatedException(e, applicationFormId)
}
return updatedApplicationForm
}
}

View File

@@ -1,34 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.application_form
import com.betriebsratkanzlei.legalconsenthub_api.model.PagedApplicationFormDto
import org.springframework.data.domain.Page
import org.springframework.stereotype.Component
import java.util.UUID
@Component
class PagedApplicationFormMapper(
private val applicationFormMapper: ApplicationFormMapper,
) {
fun toPagedApplicationFormDto(pagedApplicationForm: Page<ApplicationForm>): PagedApplicationFormDto =
toPagedApplicationFormDto(pagedApplicationForm, emptyMap())
fun toPagedApplicationFormDto(
pagedApplicationForm: Page<ApplicationForm>,
commentCountByApplicationFormId: Map<UUID, Long>,
): PagedApplicationFormDto =
PagedApplicationFormDto(
content =
pagedApplicationForm.content.map { applicationForm ->
val count = applicationForm.id?.let { commentCountByApplicationFormId[it] } ?: 0L
applicationFormMapper.toApplicationFormDto(applicationForm, count)
},
number = pagedApplicationForm.number,
propertySize = pagedApplicationForm.size,
numberOfElements = pagedApplicationForm.numberOfElements,
last = pagedApplicationForm.isLast,
totalPages = pagedApplicationForm.totalPages,
totalElements = pagedApplicationForm.totalElements,
empty = pagedApplicationForm.isEmpty,
first = pagedApplicationForm.isFirst,
)
}

View File

@@ -1,32 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.application_form.export.latex
object LatexEscaper {
fun escape(text: String?): String {
if (text == null) return ""
// First decode common HTML entities that may be present in user input
val decoded = decodeHtmlEntities(text)
// Then escape for LaTeX
return decoded
.replace("\\", "\\textbackslash{}")
.replace("{", "\\{")
.replace("}", "\\}")
.replace("$", "\\$")
.replace("&", "\\&")
.replace("#", "\\#")
.replace("%", "\\%")
.replace("_", "\\_")
.replace("^", "\\textasciicircum{}")
.replace("~", "\\textasciitilde{}")
.replace("\n", "\\\\")
}
private fun decodeHtmlEntities(text: String): String =
text
.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&#39;", "'")
.replace("&apos;", "'")
.replace("&nbsp;", " ")
}

View File

@@ -1,33 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.application_form.export.latex
import java.util.UUID
data class LatexExportModel(
val id: UUID?,
val name: String,
val organizationId: String,
val employer: String,
val worksCouncil: String,
val sections: List<LatexSection>,
val createdAt: String,
)
data class LatexSection(
val title: String,
val description: String?,
val subsections: List<LatexSubSection>,
)
data class LatexSubSection(
val title: String,
val subtitle: String?,
val elements: List<LatexFormElement>,
)
data class LatexFormElement(
val title: String,
val description: String?,
val value: String,
val isTable: Boolean = false,
val isWideTable: Boolean = false,
)

View File

@@ -1,84 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.application_form.export.latex
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.io.File
import java.nio.file.Files
import java.util.concurrent.TimeUnit
@Service
class LatexPdfRenderer {
private val logger = LoggerFactory.getLogger(LatexPdfRenderer::class.java)
fun render(latexContent: String): ByteArray {
val tempDir = Files.createTempDirectory("latex-export-").toFile()
try {
val texFile = File(tempDir, "main.tex")
texFile.writeText(latexContent)
val extraPaths = listOf("/Library/TeX/texbin", "/usr/local/bin", "/usr/bin", "/bin")
val lualatexExecutable =
extraPaths
.map { File(it, "lualatex") }
.firstOrNull { it.exists() && it.canExecute() }
?.absolutePath ?: "lualatex"
logger.info("Using lualatex executable: $lualatexExecutable")
// Run lualatex twice to resolve TOC and references
repeat(2) {
val processBuilder =
ProcessBuilder(
lualatexExecutable,
"-halt-on-error",
"-interaction=nonstopmode",
"-file-line-error",
"-output-directory=${tempDir.absolutePath}",
texFile.absolutePath,
).apply {
directory(tempDir)
redirectErrorStream(true)
}
// Ensure LaTeX binaries are in the path (common issue on macOS/local dev)
val env = processBuilder.environment()
val currentPath = env["PATH"] ?: ""
env["PATH"] = extraPaths
.filter { File(it).exists() && !currentPath.contains(it) }
.joinToString(
File.pathSeparator,
postfix = if (currentPath.isNotEmpty()) File.pathSeparator else "",
) +
currentPath
// Set TEXMFVAR and TEXMFCACHE to tempDir to avoid "no writeable cache path" error in Docker
env["TEXMFVAR"] = tempDir.absolutePath
env["TEXMFCACHE"] = tempDir.absolutePath
val process = processBuilder.start()
val completed = process.waitFor(90, TimeUnit.SECONDS)
val output = process.inputStream.bufferedReader().readText()
if (!completed) {
process.destroyForcibly()
throw RuntimeException("LaTeX compilation timed out")
}
if (process.exitValue() != 0) {
logger.error("LaTeX compilation failed:\n$output")
throw RuntimeException("LaTeX compilation failed with exit code ${process.exitValue()}")
}
}
val pdfFile = File(tempDir, "main.pdf")
if (!pdfFile.exists()) {
throw RuntimeException("PDF file was not generated")
}
return pdfFile.readBytes()
} finally {
tempDir.deleteRecursively()
}
}
}

View File

@@ -1,88 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.application_form.export.latex
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import org.springframework.stereotype.Component
@Component
class RichTextToLatexConverter(
private val objectMapper: ObjectMapper,
) {
fun convertToLatex(jsonString: String?): String {
if (jsonString.isNullOrBlank()) return ""
return try {
val root = objectMapper.readTree(jsonString)
processNode(root)
} catch (e: Exception) {
// Fallback to escaped plain text if JSON parsing fails (e.g. legacy HTML)
// In a real scenario, we might want to strip HTML tags here too.
LatexEscaper.escape(jsonString.replace(Regex("<[^>]*>"), ""))
}
}
private fun processNode(node: JsonNode): String {
val type = node.get("type")?.asText() ?: return ""
val content = node.get("content")
return when (type) {
"doc" -> processContent(content)
"paragraph" -> processContent(content) + "\n\n"
"heading" -> {
val level = node.get("attrs")?.get("level")?.asInt() ?: 1
val cmd =
when (level) {
1 -> "\\subsubsection*{"
2 -> "\\paragraph*{"
else -> "\\subparagraph*{"
}
cmd + processContent(content) + "}\n\n"
}
"bulletList" -> "\\begin{itemize}\n" + processContent(content) + "\\end{itemize}\n"
"orderedList" -> "\\begin{enumerate}\n" + processContent(content) + "\\end{enumerate}\n"
"listItem" -> "\\item " + processContent(content) + "\n"
"blockquote" -> "\\begin{quotation}\n" + processContent(content) + "\\end{quotation}\n"
"codeBlock" -> "\\begin{verbatim}\n" + processContent(content) + "\\end{verbatim}\n"
"text" -> {
val text = node.get("text")?.asText() ?: ""
val escapedText = LatexEscaper.escape(text)
applyMarks(escapedText, node.get("marks"))
}
"hardBreak" -> "\\\\\n"
else -> ""
}
}
private fun processContent(content: JsonNode?): String {
if (content == null || !content.isArray) return ""
val sb = StringBuilder()
for (node in content) {
sb.append(processNode(node))
}
return sb.toString()
}
private fun applyMarks(
text: String,
marks: JsonNode?,
): String {
if (marks == null || !marks.isArray) return text
var result = text
for (mark in marks) {
val markType = mark.get("type")?.asText() ?: continue
result =
when (markType) {
"bold" -> "\\textbf{$result}"
"italic" -> "\\textit{$result}"
"underline" -> "\\underline{$result}"
"strike" -> "\\sout{$result}" // Requires ulem package
"link" -> {
val href = mark.get("attrs")?.get("href")?.asText() ?: ""
"\\href{$href}{$result}"
}
else -> result
}
}
return result
}
}

View File

@@ -1,73 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.application_form_template
import com.betriebsratkanzlei.legalconsenthub.application_form.ApplicationFormMapper
import com.betriebsratkanzlei.legalconsenthub.application_form.PagedApplicationFormMapper
import com.betriebsratkanzlei.legalconsenthub_api.api.ApplicationFormTemplateApi
import com.betriebsratkanzlei.legalconsenthub_api.model.ApplicationFormDto
import com.betriebsratkanzlei.legalconsenthub_api.model.PagedApplicationFormDto
import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.RestController
import java.util.UUID
@RestController
class ApplicationFormTemplateController(
val applicationFormTemplateService: ApplicationFormTemplateService,
val pagedApplicationFormMapper: PagedApplicationFormMapper,
val applicationFormMapper: ApplicationFormMapper,
) : ApplicationFormTemplateApi {
@PreAuthorize(
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL')",
)
override fun createApplicationFormTemplate(
applicationFormDto: ApplicationFormDto,
): ResponseEntity<ApplicationFormDto> =
ResponseEntity.ok(
applicationFormMapper.toApplicationFormDto(
applicationFormTemplateService.createApplicationFormTemplate(
applicationFormDto.copy(isTemplate = true),
),
),
)
@PreAuthorize(
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL')",
)
override fun getAllApplicationFormTemplates(): ResponseEntity<PagedApplicationFormDto> =
ResponseEntity.ok(
pagedApplicationFormMapper.toPagedApplicationFormDto(
applicationFormTemplateService.getApplicationFormTemplates(),
),
)
@PreAuthorize(
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL')",
)
override fun getApplicationFormTemplateById(id: UUID): ResponseEntity<ApplicationFormDto> =
ResponseEntity.ok(
applicationFormMapper.toApplicationFormDto(
applicationFormTemplateService.getApplicationFormTemplateById(id),
),
)
@PreAuthorize(
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL')",
)
override fun updateApplicationFormTemplate(
id: UUID,
applicationFormDto: ApplicationFormDto,
): ResponseEntity<ApplicationFormDto> =
ResponseEntity.ok(
applicationFormMapper.toApplicationFormDto(
applicationFormTemplateService.updateApplicationFormTemplate(id, applicationFormDto),
),
)
@PreAuthorize(
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL')",
)
override fun deleteApplicationFormTemplate(id: UUID): ResponseEntity<Unit> {
applicationFormTemplateService.deleteApplicationFormTemplateByID(id)
return ResponseEntity.noContent().build()
}
}

View File

@@ -1,72 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.application_form_template
import com.betriebsratkanzlei.legalconsenthub.application_form.ApplicationForm
import com.betriebsratkanzlei.legalconsenthub.application_form.ApplicationFormMapper
import com.betriebsratkanzlei.legalconsenthub.application_form.ApplicationFormRepository
import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotCreatedException
import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotDeletedException
import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotFoundException
import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotUpdatedException
import com.betriebsratkanzlei.legalconsenthub_api.model.ApplicationFormDto
import org.springframework.data.domain.Page
import org.springframework.data.domain.PageRequest
import org.springframework.stereotype.Service
import java.util.UUID
@Service
class ApplicationFormTemplateService(
private val applicationFormRepository: ApplicationFormRepository,
private val applicationFormMapper: ApplicationFormMapper,
) {
fun createApplicationFormTemplate(applicationFormDto: ApplicationFormDto): ApplicationForm {
val applicationForm = applicationFormMapper.toNewApplicationForm(applicationFormDto)
val savedApplicationForm: ApplicationForm
try {
savedApplicationForm = applicationFormRepository.save(applicationForm)
} catch (e: Exception) {
throw ApplicationFormNotCreatedException(e)
}
return savedApplicationForm
}
fun getApplicationFormTemplateById(id: UUID): ApplicationForm =
applicationFormRepository.findById(id).orElseThrow {
ApplicationFormNotFoundException(id)
}
fun getApplicationFormTemplates(): Page<ApplicationForm> {
val pageable = PageRequest.of(0, 10)
return applicationFormRepository.findAllByIsTemplateTrue(pageable)
}
fun updateApplicationFormTemplate(
id: UUID,
applicationFormDto: ApplicationFormDto,
): ApplicationForm {
val existingApplicationForm = getApplicationFormTemplateById(id)
val applicationForm =
applicationFormMapper.toUpdatedApplicationForm(
id,
applicationFormDto,
existingApplicationForm,
)
val updatedApplicationForm: ApplicationForm
try {
updatedApplicationForm = applicationFormRepository.save(applicationForm)
} catch (e: Exception) {
throw ApplicationFormNotUpdatedException(e, id)
}
return updatedApplicationForm
}
fun deleteApplicationFormTemplateByID(id: UUID) {
try {
applicationFormRepository.deleteById(id)
} catch (e: Exception) {
throw ApplicationFormNotDeletedException(e)
}
}
}

View File

@@ -1,49 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.application_form_version
import com.betriebsratkanzlei.legalconsenthub.application_form.ApplicationForm
import com.betriebsratkanzlei.legalconsenthub.user.User
import com.betriebsratkanzlei.legalconsenthub_api.model.ApplicationFormStatus
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.EntityListeners
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
import jakarta.persistence.GeneratedValue
import jakarta.persistence.Id
import jakarta.persistence.JoinColumn
import jakarta.persistence.ManyToOne
import org.hibernate.annotations.OnDelete
import org.hibernate.annotations.OnDeleteAction
import org.springframework.data.annotation.CreatedDate
import org.springframework.data.jpa.domain.support.AuditingEntityListener
import java.time.Instant
import java.util.UUID
@Entity
@EntityListeners(AuditingEntityListener::class)
class ApplicationFormVersion(
@Id
@GeneratedValue
var id: UUID? = null,
@ManyToOne
@JoinColumn(name = "application_form_id", nullable = false)
@OnDelete(action = OnDeleteAction.CASCADE)
var applicationForm: ApplicationForm,
@Column(nullable = false)
var versionNumber: Int,
@Column(nullable = false)
var name: String,
@Enumerated(EnumType.STRING)
@Column(nullable = false)
var status: ApplicationFormStatus,
@Column(nullable = false)
var organizationId: String,
@Column(nullable = false, columnDefinition = "TEXT")
var snapshotData: String,
@ManyToOne
@JoinColumn(name = "created_by_id", nullable = false)
var createdBy: User,
@CreatedDate
@Column(nullable = false)
var createdAt: Instant? = null,
)

View File

@@ -1,73 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.application_form_version
import com.betriebsratkanzlei.legalconsenthub.application_form.ApplicationFormMapper
import com.betriebsratkanzlei.legalconsenthub.application_form_version.pdf.ApplicationFormVersionPdfService
import com.betriebsratkanzlei.legalconsenthub.user.UserService
import com.betriebsratkanzlei.legalconsenthub_api.api.ApplicationFormVersionApi
import com.betriebsratkanzlei.legalconsenthub_api.model.ApplicationFormDto
import com.betriebsratkanzlei.legalconsenthub_api.model.ApplicationFormVersionDto
import com.betriebsratkanzlei.legalconsenthub_api.model.ApplicationFormVersionListItemDto
import org.springframework.core.io.ByteArrayResource
import org.springframework.core.io.Resource
import org.springframework.http.HttpHeaders
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.RestController
import java.util.UUID
@RestController
class ApplicationFormVersionController(
private val versionService: ApplicationFormVersionService,
private val versionMapper: ApplicationFormVersionMapper,
private val applicationFormMapper: ApplicationFormMapper,
private val userService: UserService,
private val versionPdfService: ApplicationFormVersionPdfService,
) : ApplicationFormVersionApi {
@PreAuthorize(
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL', 'EMPLOYEE')",
)
override fun getApplicationFormVersions(id: UUID): ResponseEntity<List<ApplicationFormVersionListItemDto>> {
val versions = versionService.getVersionsByApplicationFormId(id)
return ResponseEntity.ok(versions.map { versionMapper.toApplicationFormVersionListItemDto(it) })
}
@PreAuthorize(
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL', 'EMPLOYEE')",
)
override fun getApplicationFormVersion(
id: UUID,
versionNumber: Int,
): ResponseEntity<ApplicationFormVersionDto> {
val version = versionService.getVersion(id, versionNumber)
return ResponseEntity.ok(versionMapper.toApplicationFormVersionDto(version))
}
@PreAuthorize(
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL', 'EMPLOYEE')",
)
override fun getApplicationFormVersionPdf(
id: UUID,
versionNumber: Int,
): ResponseEntity<Resource> {
val pdfBytes = versionPdfService.getOrGeneratePdf(id, versionNumber)
val resource = ByteArrayResource(pdfBytes)
return ResponseEntity
.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"form-$id-v$versionNumber.pdf\"")
.contentType(MediaType.APPLICATION_PDF)
.body(resource)
}
@PreAuthorize(
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL')",
)
override fun restoreApplicationFormVersion(
id: UUID,
versionNumber: Int,
): ResponseEntity<ApplicationFormDto> {
val currentUser = userService.getCurrentUser()
val restoredForm = versionService.restoreVersion(id, versionNumber, currentUser)
return ResponseEntity.ok(applicationFormMapper.toApplicationFormDto(restoredForm))
}
}

View File

@@ -1,50 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.application_form_version
import com.betriebsratkanzlei.legalconsenthub.user.UserMapper
import com.betriebsratkanzlei.legalconsenthub_api.model.ApplicationFormSnapshotDto
import com.betriebsratkanzlei.legalconsenthub_api.model.ApplicationFormVersionDto
import com.betriebsratkanzlei.legalconsenthub_api.model.ApplicationFormVersionListItemDto
import com.fasterxml.jackson.databind.ObjectMapper
import org.springframework.stereotype.Component
@Component
class ApplicationFormVersionMapper(
private val userMapper: UserMapper,
private val objectMapper: ObjectMapper,
) {
fun toApplicationFormVersionDto(version: ApplicationFormVersion): ApplicationFormVersionDto {
val snapshot = objectMapper.readValue(version.snapshotData, ApplicationFormSnapshotDto::class.java)
return ApplicationFormVersionDto(
id = version.id ?: throw IllegalStateException("ApplicationFormVersion ID must not be null!"),
applicationFormId =
version.applicationForm.id
?: throw IllegalStateException("ApplicationForm ID must not be null!"),
versionNumber = version.versionNumber,
name = version.name,
status = version.status,
organizationId = version.organizationId,
snapshot = snapshot,
createdBy = userMapper.toUserDto(version.createdBy),
createdAt =
version.createdAt
?: throw IllegalStateException("ApplicationFormVersion createdAt must not be null!"),
)
}
fun toApplicationFormVersionListItemDto(version: ApplicationFormVersion): ApplicationFormVersionListItemDto =
ApplicationFormVersionListItemDto(
id = version.id ?: throw IllegalStateException("ApplicationFormVersion ID must not be null!"),
applicationFormId =
version.applicationForm.id
?: throw IllegalStateException("ApplicationForm ID must not be null!"),
versionNumber = version.versionNumber,
name = version.name,
status = version.status,
organizationId = version.organizationId,
createdBy = userMapper.toUserDto(version.createdBy),
createdAt =
version.createdAt
?: throw IllegalStateException("ApplicationFormVersion createdAt must not be null!"),
)
}

View File

@@ -1,18 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.application_form_version
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
import java.util.Optional
import java.util.UUID
@Repository
interface ApplicationFormVersionRepository : JpaRepository<ApplicationFormVersion, UUID> {
fun findByApplicationFormIdOrderByVersionNumberDesc(applicationFormId: UUID): List<ApplicationFormVersion>
fun findByApplicationFormIdAndVersionNumber(
applicationFormId: UUID,
versionNumber: Int,
): Optional<ApplicationFormVersion>
fun countByApplicationFormId(applicationFormId: UUID): Int
}

View File

@@ -1,209 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.application_form_version
import com.betriebsratkanzlei.legalconsenthub.application_form.ApplicationForm
import com.betriebsratkanzlei.legalconsenthub.application_form.ApplicationFormRepository
import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotFoundException
import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormVersionNotFoundException
import com.betriebsratkanzlei.legalconsenthub.form_element.FormElement
import com.betriebsratkanzlei.legalconsenthub.form_element.FormElementSection
import com.betriebsratkanzlei.legalconsenthub.form_element.FormElementSubSection
import com.betriebsratkanzlei.legalconsenthub.form_element.FormElementVisibilityConditionMapper
import com.betriebsratkanzlei.legalconsenthub.form_element.FormOptionMapper
import com.betriebsratkanzlei.legalconsenthub.form_element.SectionSpawnTriggerMapper
import com.betriebsratkanzlei.legalconsenthub.form_element.TableRowPresetMapper
import com.betriebsratkanzlei.legalconsenthub.user.User
import com.betriebsratkanzlei.legalconsenthub_api.model.ApplicationFormSnapshotDto
import com.betriebsratkanzlei.legalconsenthub_api.model.FormElementSectionSnapshotDto
import com.betriebsratkanzlei.legalconsenthub_api.model.FormElementSnapshotDto
import com.betriebsratkanzlei.legalconsenthub_api.model.FormElementSubSectionSnapshotDto
import com.fasterxml.jackson.databind.ObjectMapper
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.util.UUID
@Service
class ApplicationFormVersionService(
private val versionRepository: ApplicationFormVersionRepository,
private val applicationFormRepository: ApplicationFormRepository,
private val objectMapper: ObjectMapper,
private val spawnTriggerMapper: SectionSpawnTriggerMapper,
private val visibilityConditionMapper: FormElementVisibilityConditionMapper,
private val tableRowPresetMapper: TableRowPresetMapper,
private val formOptionMapper: FormOptionMapper,
) {
@Transactional
fun createVersion(
applicationForm: ApplicationForm,
user: User,
): ApplicationFormVersion {
val nextVersionNumber = versionRepository.countByApplicationFormId(applicationForm.id!!) + 1
val snapshot = createSnapshot(applicationForm)
val snapshotJson = objectMapper.writeValueAsString(snapshot)
val version =
ApplicationFormVersion(
applicationForm = applicationForm,
versionNumber = nextVersionNumber,
name = applicationForm.name,
status = applicationForm.status,
organizationId = applicationForm.organizationId,
snapshotData = snapshotJson,
createdBy = user,
)
return versionRepository.save(version)
}
fun getVersionsByApplicationFormId(applicationFormId: UUID): List<ApplicationFormVersion> =
versionRepository.findByApplicationFormIdOrderByVersionNumberDesc(applicationFormId)
fun getVersion(
applicationFormId: UUID,
versionNumber: Int,
): ApplicationFormVersion =
versionRepository
.findByApplicationFormIdAndVersionNumber(applicationFormId, versionNumber)
.orElseThrow { ApplicationFormVersionNotFoundException(applicationFormId, versionNumber) }
@Transactional
fun restoreVersion(
applicationFormId: UUID,
versionNumber: Int,
user: User,
): ApplicationForm {
val version = getVersion(applicationFormId, versionNumber)
val applicationForm =
applicationFormRepository
.findById(applicationFormId)
.orElseThrow { ApplicationFormNotFoundException(applicationFormId) }
val snapshot = objectMapper.readValue(version.snapshotData, ApplicationFormSnapshotDto::class.java)
applicationForm.name = snapshot.name
applicationForm.status = snapshot.status
applicationForm.organizationId = snapshot.organizationId
applicationForm.lastModifiedBy = user
applicationForm.formElementSections.clear()
snapshot.sections.forEach { sectionSnapshot ->
val section = createSectionFromSnapshot(sectionSnapshot, applicationForm)
applicationForm.formElementSections.add(section)
}
val restoredForm = applicationFormRepository.save(applicationForm)
createVersion(restoredForm, user)
return restoredForm
}
fun createSnapshot(applicationForm: ApplicationForm): ApplicationFormSnapshotDto =
ApplicationFormSnapshotDto(
name = applicationForm.name,
status = applicationForm.status,
organizationId = applicationForm.organizationId,
sections =
applicationForm.formElementSections.map { section ->
FormElementSectionSnapshotDto(
title = section.title,
shortTitle = section.shortTitle,
description = section.description,
isTemplate = section.isTemplate,
templateReference = section.templateReference,
titleTemplate = section.titleTemplate,
spawnedFromElementReference = section.spawnedFromElementReference,
subsections =
section.formElementSubSections.map { subsection ->
FormElementSubSectionSnapshotDto(
title = subsection.title,
subtitle = subsection.subtitle,
elements =
subsection.formElements.map { element ->
FormElementSnapshotDto(
reference = element.reference,
title = element.title,
description = element.description,
type = element.type,
options = element.options.map { formOptionMapper.toFormOptionDto(it) },
visibilityConditions =
element.visibilityConditions?.let {
visibilityConditionMapper.toGroupConditionDto(it)
},
sectionSpawnTriggers =
element.sectionSpawnTriggers.map {
spawnTriggerMapper.toSectionSpawnTriggerDto(it)
},
isClonable = element.isClonable,
tableRowPreset =
element.tableRowPreset?.let {
tableRowPresetMapper.toTableRowPresetDto(it)
},
)
},
)
},
)
},
)
private fun createSectionFromSnapshot(
sectionSnapshot: FormElementSectionSnapshotDto,
applicationForm: ApplicationForm,
): FormElementSection {
val section =
FormElementSection(
title = sectionSnapshot.title,
shortTitle = sectionSnapshot.shortTitle,
description = sectionSnapshot.description,
isTemplate = sectionSnapshot.isTemplate ?: false,
templateReference = sectionSnapshot.templateReference,
titleTemplate = sectionSnapshot.titleTemplate,
spawnedFromElementReference = sectionSnapshot.spawnedFromElementReference,
applicationForm = applicationForm,
)
sectionSnapshot.subsections.forEach { subsectionSnapshot ->
val subsection =
FormElementSubSection(
title = subsectionSnapshot.title,
subtitle = subsectionSnapshot.subtitle,
formElementSection = section,
)
subsectionSnapshot.elements.forEach { elementSnapshot ->
val element =
FormElement(
reference = elementSnapshot.reference,
title = elementSnapshot.title,
description = elementSnapshot.description,
type = elementSnapshot.type,
formElementSubSection = subsection,
options =
elementSnapshot.options
.map { formOptionMapper.toFormOption(it) }
.toMutableList(),
visibilityConditions =
elementSnapshot.visibilityConditions?.let {
visibilityConditionMapper.toGroupCondition(it)
},
sectionSpawnTriggers =
elementSnapshot.sectionSpawnTriggers
?.map { spawnTriggerMapper.toSectionSpawnTrigger(it) }
?.toMutableList()
?: mutableListOf(),
isClonable = elementSnapshot.isClonable ?: false,
tableRowPreset =
elementSnapshot.tableRowPreset?.let {
tableRowPresetMapper.toTableRowPreset(it)
},
)
subsection.formElements.add(element)
}
section.formElementSubSections.add(subsection)
}
return section
}
}

View File

@@ -1,61 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.application_form_version.pdf
import com.betriebsratkanzlei.legalconsenthub.application_form.ApplicationFormFormatService
import com.betriebsratkanzlei.legalconsenthub.application_form_version.ApplicationFormVersionService
import com.betriebsratkanzlei.legalconsenthub_api.model.ApplicationFormSnapshotDto
import com.fasterxml.jackson.databind.ObjectMapper
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.locks.ReentrantLock
@Service
class ApplicationFormVersionPdfService(
private val versionService: ApplicationFormVersionService,
private val objectMapper: ObjectMapper,
private val formatService: ApplicationFormFormatService,
private val pdfStorage: PdfStorage,
) {
private val logger = LoggerFactory.getLogger(ApplicationFormVersionPdfService::class.java)
private val locks = ConcurrentHashMap<PdfStorageKey, ReentrantLock>()
fun getOrGeneratePdf(
applicationFormId: UUID,
versionNumber: Int,
): ByteArray {
val version = versionService.getVersion(applicationFormId, versionNumber)
val key = PdfStorageKey(applicationFormId, versionNumber)
pdfStorage.get(key)?.let {
logger.debug(
"Serving cached version PDF: applicationFormId={} version={}",
applicationFormId,
versionNumber,
)
return it
}
val lock = locks.computeIfAbsent(key) { ReentrantLock() }
lock.lock()
try {
pdfStorage.get(key)?.let {
logger.debug(
"Serving cached version PDF after lock: applicationFormId={} version={}",
applicationFormId,
versionNumber,
)
return it
}
logger.info("Generating version PDF: applicationFormId=$applicationFormId version=$versionNumber")
val snapshot = objectMapper.readValue(version.snapshotData, ApplicationFormSnapshotDto::class.java)
val pdfBytes = formatService.generatePdf(snapshot, version.createdAt)
pdfStorage.put(key, pdfBytes)
return pdfBytes
} finally {
lock.unlock()
}
}
}

View File

@@ -1,61 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.application_form_version.pdf
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Component
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.StandardCopyOption
import kotlin.io.path.exists
import kotlin.io.path.inputStream
@Component
class FileSystemPdfStorage(
private val properties: PdfStorageProperties,
) : PdfStorage {
private val logger = LoggerFactory.getLogger(FileSystemPdfStorage::class.java)
override fun get(key: PdfStorageKey): ByteArray? {
val path = resolvePath(key)
if (!path.exists()) return null
return path.inputStream().use { it.readBytes() }
}
override fun put(
key: PdfStorageKey,
bytes: ByteArray,
) {
val targetPath = resolvePath(key)
Files.createDirectories(targetPath.parent)
val tmpFile = Files.createTempFile(targetPath.parent, targetPath.fileName.toString(), ".tmp")
try {
Files.write(tmpFile, bytes)
try {
Files.move(
tmpFile,
targetPath,
StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING,
)
} catch (e: Exception) {
logger.debug("Atomic move failed, falling back to non-atomic move: ${e.message}")
Files.move(
tmpFile,
targetPath,
StandardCopyOption.REPLACE_EXISTING,
)
}
} finally {
try {
Files.deleteIfExists(tmpFile)
} catch (_: Exception) {
// ignore
}
}
}
private fun resolvePath(key: PdfStorageKey): Path {
val baseDir = Path.of(properties.filesystem.baseDir)
return key.toPathParts().fold(baseDir) { acc, part -> acc.resolve(part) }
}
}

View File

@@ -1,10 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.application_form_version.pdf
interface PdfStorage {
fun get(key: PdfStorageKey): ByteArray?
fun put(
key: PdfStorageKey,
bytes: ByteArray,
)
}

View File

@@ -1,14 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.application_form_version.pdf
import java.util.UUID
data class PdfStorageKey(
val applicationFormId: UUID,
val versionNumber: Int,
) {
fun toPathParts(): List<String> =
listOf(
applicationFormId.toString(),
"v$versionNumber.pdf",
)
}

View File

@@ -1,33 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.application_form_version.pdf
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.boot.context.properties.NestedConfigurationProperty
import org.springframework.context.annotation.Configuration
@Configuration
@EnableConfigurationProperties(PdfStorageProperties::class)
class PdfStorageConfiguration
@ConfigurationProperties(prefix = "legalconsenthub.pdf.storage")
data class PdfStorageProperties(
@NestedConfigurationProperty
val filesystem: FileSystemProperties = FileSystemProperties(),
) {
data class FileSystemProperties(
/**
* Base directory for stored PDFs. In development this defaults to a folder next to the backend code.
*
* Configure either via application.yaml:
* legalconsenthub:
* pdf:
* storage:
* filesystem:
* base-dir: /var/lib/legalconsenthub/pdfs
*
* or via environment variable:
* LEGALCONSENTHUB_PDF_STORAGE_FILESYSTEM_BASE_DIR=/var/lib/legalconsenthub/pdfs
*/
val baseDir: String = ".pdf-store",
)
}

View File

@@ -1,42 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.comment
import com.betriebsratkanzlei.legalconsenthub.application_form.ApplicationForm
import com.betriebsratkanzlei.legalconsenthub.form_element.FormElement
import com.betriebsratkanzlei.legalconsenthub.user.User
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.EntityListeners
import jakarta.persistence.GeneratedValue
import jakarta.persistence.Id
import jakarta.persistence.JoinColumn
import jakarta.persistence.ManyToOne
import org.springframework.data.annotation.CreatedDate
import org.springframework.data.annotation.LastModifiedDate
import org.springframework.data.jpa.domain.support.AuditingEntityListener
import java.time.Instant
import java.util.UUID
@Entity
@EntityListeners(AuditingEntityListener::class)
class Comment(
@Id
@GeneratedValue
var id: UUID? = null,
@Column(nullable = false, columnDefinition = "TEXT")
var message: String = "",
@ManyToOne
@JoinColumn(name = "created_by_id", nullable = false)
var createdBy: User,
@CreatedDate
@Column(nullable = false)
var createdAt: Instant? = null,
@LastModifiedDate
@Column(nullable = false)
var modifiedAt: Instant? = null,
@ManyToOne
@JoinColumn(name = "application_form_id", nullable = false)
var applicationForm: ApplicationForm? = null,
@ManyToOne
@JoinColumn(name = "form_element_id", nullable = false)
var formElement: FormElement? = null,
)

View File

@@ -1,87 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.comment
import com.betriebsratkanzlei.legalconsenthub_api.api.CommentApi
import com.betriebsratkanzlei.legalconsenthub_api.model.ApplicationFormCommentCountsDto
import com.betriebsratkanzlei.legalconsenthub_api.model.CommentDto
import com.betriebsratkanzlei.legalconsenthub_api.model.CreateCommentDto
import com.betriebsratkanzlei.legalconsenthub_api.model.CursorPagedCommentDto
import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.RestController
import java.time.Instant
import java.util.UUID
@RestController
class CommentController(
val commentService: CommentService,
val commentMapper: CommentMapper,
) : CommentApi {
@PreAuthorize(
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL', 'EMPLOYEE')",
)
override fun createComment(
applicationFormId: UUID,
formElementId: UUID,
createCommentDto: CreateCommentDto,
): ResponseEntity<CommentDto> =
ResponseEntity.ok(
commentMapper.toCommentDto(
commentService.createComment(applicationFormId, formElementId, createCommentDto),
),
)
@PreAuthorize(
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL', 'EMPLOYEE')",
)
override fun getGroupedCommentCountByApplicationFromId(
applicationFormId: UUID,
): ResponseEntity<ApplicationFormCommentCountsDto> {
val counts = commentService.getGroupedCommentCountByApplicationFromId(applicationFormId)
return ResponseEntity.ok(
ApplicationFormCommentCountsDto(
counts = counts.mapKeys { it.key.toString() },
),
)
}
@PreAuthorize(
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL', 'EMPLOYEE')",
)
override fun getCommentsByApplicationFormId(
applicationFormId: UUID,
formElementId: UUID?,
cursorCreatedAt: Instant?,
limit: Int,
): ResponseEntity<CursorPagedCommentDto> {
val page = commentService.getComments(applicationFormId, formElementId, cursorCreatedAt, limit)
return ResponseEntity.ok(
CursorPagedCommentDto(
content = page.comments.map { commentMapper.toCommentDto(it) },
nextCursorCreatedAt = page.nextCursorCreatedAt,
hasMore = page.hasMore,
),
)
}
@PreAuthorize(
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL', 'EMPLOYEE')",
)
override fun updateComment(
id: UUID,
commentDto: CommentDto,
): ResponseEntity<CommentDto> =
ResponseEntity.ok(
commentMapper.toCommentDto(
commentService.updateComment(id, commentDto),
),
)
@PreAuthorize(
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL')",
)
override fun deleteComment(id: UUID): ResponseEntity<Unit> {
commentService.deleteCommentByID(id)
return ResponseEntity.noContent().build()
}
}

View File

@@ -1,90 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.comment
import com.betriebsratkanzlei.legalconsenthub.application_form.ApplicationFormRepository
import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotFoundException
import com.betriebsratkanzlei.legalconsenthub.error.FormElementNotFoundException
import com.betriebsratkanzlei.legalconsenthub.form_element.FormElementRepository
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.CreateCommentDto
import org.springframework.stereotype.Component
import java.time.Instant
import java.util.UUID
@Component
class CommentMapper(
private val userMapper: UserMapper,
private val userService: UserService,
private val applicationFormRepository: ApplicationFormRepository,
private val formElementRepository: FormElementRepository,
) {
fun toCommentDto(comment: Comment): CommentDto =
CommentDto(
id = comment.id ?: throw IllegalStateException("Comment ID must not be null!"),
message = comment.message,
createdAt = comment.createdAt ?: Instant.now(),
modifiedAt = comment.modifiedAt ?: Instant.now(),
createdBy = userMapper.toUserDto(comment.createdBy),
applicationFormId =
comment.applicationForm?.id
?: throw IllegalStateException("ApplicationForm ID must not be null!"),
formElementId =
comment.formElement?.id ?: throw IllegalStateException(
"FormElement ID must not be null!",
),
)
fun toComment(commentDto: CommentDto): Comment {
val applicationForm =
applicationFormRepository
.findById(commentDto.applicationFormId)
.orElseThrow { ApplicationFormNotFoundException(commentDto.applicationFormId) }
val formElement =
formElementRepository
.findById(commentDto.formElementId)
.orElseThrow { FormElementNotFoundException(commentDto.formElementId) }
return Comment(
id = commentDto.id,
message = commentDto.message,
createdAt = commentDto.createdAt,
modifiedAt = commentDto.modifiedAt,
createdBy = userMapper.toUser(commentDto.createdBy),
applicationForm = applicationForm,
formElement = formElement,
)
}
fun applyUpdate(
existing: Comment,
commentDto: CommentDto,
): Comment {
existing.message = commentDto.message
return existing
}
fun toComment(
applicationFormId: UUID,
formElementId: UUID,
commentDto: CreateCommentDto,
): Comment {
val applicationForm =
applicationFormRepository
.findById(applicationFormId)
.orElseThrow { FormElementNotFoundException(applicationFormId) }
val formElement =
formElementRepository
.findById(formElementId)
.orElseThrow { FormElementNotFoundException(formElementId) }
val currentUser = userService.getCurrentUser()
return Comment(
message = commentDto.message,
createdBy = currentUser,
applicationForm = applicationForm,
formElement = formElement,
)
}
}

View File

@@ -1,71 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.comment
import com.betriebsratkanzlei.legalconsenthub.application_form.ApplicationForm
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.Query
import org.springframework.data.repository.query.Param
import org.springframework.stereotype.Repository
import java.time.Instant
import java.util.UUID
interface ApplicationFormCommentCountProjection {
val applicationFormId: UUID
val commentCount: Long
}
interface FormElementCommentCountProjection {
val formElementId: UUID
val commentCount: Long
}
@Repository
interface CommentRepository : JpaRepository<Comment, UUID> {
fun findAllByApplicationForm(
applicationForm: ApplicationForm,
pageable: Pageable,
): Page<Comment>
@Query(
"""
select c.applicationForm.id as applicationFormId, count(c.id) as commentCount
from Comment c
where c.applicationForm.id in :applicationFormIds
group by c.applicationForm.id
""",
)
fun countByApplicationFormIds(
@Param("applicationFormIds") applicationFormIds: Collection<UUID>,
): List<ApplicationFormCommentCountProjection>
@Query(
"""
select c.*
from comment c
where c.application_form_id = :applicationFormId
and (cast(:formElementId as uuid) is null or c.form_element_id = :formElementId)
and (cast(:cursorCreatedAt as timestamp) is null or c.created_at < :cursorCreatedAt)
order by c.created_at desc, c.id desc
""",
nativeQuery = true,
)
fun findNextByApplicationFormId(
@Param("applicationFormId") applicationFormId: UUID,
@Param("formElementId") formElementId: UUID?,
@Param("cursorCreatedAt") cursorCreatedAt: Instant?,
pageable: Pageable,
): List<Comment>
@Query(
"""
select c.formElement.id as formElementId, count(c.id) as commentCount
from Comment c
where c.applicationForm.id = :applicationFormId
group by c.formElement.id
""",
)
fun countByApplicationFormIdGroupByFormElementId(
@Param("applicationFormId") applicationFormId: UUID,
): List<FormElementCommentCountProjection>
}

View File

@@ -1,216 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.comment
import com.betriebsratkanzlei.legalconsenthub.application_form.ApplicationFormRepository
import com.betriebsratkanzlei.legalconsenthub.email.CommentAddedEvent
import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotFoundException
import com.betriebsratkanzlei.legalconsenthub.error.CommentNotCreatedException
import com.betriebsratkanzlei.legalconsenthub.error.CommentNotDeletedException
import com.betriebsratkanzlei.legalconsenthub.error.CommentNotFoundException
import com.betriebsratkanzlei.legalconsenthub.error.CommentNotUpdatedException
import com.betriebsratkanzlei.legalconsenthub.notification.NotificationService
import com.betriebsratkanzlei.legalconsenthub_api.model.CommentDto
import com.betriebsratkanzlei.legalconsenthub_api.model.CreateCommentDto
import com.betriebsratkanzlei.legalconsenthub_api.model.CreateNotificationDto
import com.betriebsratkanzlei.legalconsenthub_api.model.NotificationType
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import org.springframework.context.ApplicationEventPublisher
import org.springframework.data.domain.PageRequest
import org.springframework.stereotype.Service
import java.time.Instant
import java.util.UUID
data class CursorCommentPage(
val comments: List<Comment>,
val nextCursorCreatedAt: Instant?,
val hasMore: Boolean,
)
@Service
class CommentService(
private val commentRepository: CommentRepository,
private val applicationFormRepository: ApplicationFormRepository,
private val commentMapper: CommentMapper,
private val notificationService: NotificationService,
private val eventPublisher: ApplicationEventPublisher,
private val objectMapper: ObjectMapper,
) {
fun createComment(
applicationFormId: UUID,
formElementId: UUID,
createCommentDto: CreateCommentDto,
): Comment {
val comment = commentMapper.toComment(applicationFormId, formElementId, createCommentDto)
val savedComment: Comment
try {
savedComment = commentRepository.save(comment)
} catch (e: Exception) {
throw CommentNotCreatedException(e)
}
// Notify the form author if someone else added a comment
val applicationForm =
applicationFormRepository
.findById(applicationFormId)
.orElseThrow { ApplicationFormNotFoundException(applicationFormId) }
if (applicationForm.createdBy.keycloakId != savedComment.createdBy.keycloakId) {
createNotificationForNewComment(
savedComment,
applicationForm.createdBy.keycloakId,
applicationForm.organizationId,
)
}
return savedComment
}
private fun createNotificationForNewComment(
comment: Comment,
formAuthorKeycloakId: String,
organizationId: String,
) {
val formName = comment.applicationForm?.name ?: "Unbekannt"
val title = "Neuer Kommentar zu Ihrem Mitbestimmungsantrag"
val message =
"${comment.createdBy.name} hat einen Kommentar zu Ihrem Mitbestimmungsantrag " +
"'$formName' hinzugefügt."
val clickTarget = "/application-forms/${comment.applicationForm?.id}/0"
val createNotificationDto =
CreateNotificationDto(
title = title,
message = message,
clickTarget = clickTarget,
recipientId = formAuthorKeycloakId,
targetRoles = null,
excludedUserId = null,
type = NotificationType.INFO,
organizationId = organizationId,
)
notificationService.createNotificationForUser(createNotificationDto)
// Publish email event for form author
val plainText = extractPlainTextFromTipTap(comment.message)
val commentPreview =
if (plainText.length > 100) {
plainText.take(100) + "..."
} else {
plainText
}
eventPublisher.publishEvent(
CommentAddedEvent(
applicationFormId = comment.applicationForm?.id!!,
organizationId = organizationId,
commenterName = comment.createdBy.name,
formName = formName,
authorKeycloakId = formAuthorKeycloakId,
commentPreview = commentPreview,
),
)
}
fun getCommentById(id: UUID): Comment = commentRepository.findById(id).orElseThrow { CommentNotFoundException(id) }
fun getComments(
applicationFormId: UUID,
formElementId: UUID?,
cursorCreatedAt: Instant?,
limit: Int,
): CursorCommentPage {
applicationFormRepository.findById(applicationFormId).orElse(null)
val pageSize = limit.coerceIn(1, 50)
val fetchSize = pageSize + 1
val comments =
commentRepository.findNextByApplicationFormId(
applicationFormId = applicationFormId,
formElementId = formElementId,
cursorCreatedAt = cursorCreatedAt,
pageable = PageRequest.of(0, fetchSize),
)
val hasMore = comments.size > pageSize
val pageItems = if (hasMore) comments.take(pageSize) else comments
val nextCursor = if (hasMore) pageItems.lastOrNull()?.createdAt else null
return CursorCommentPage(
comments = pageItems,
nextCursorCreatedAt = nextCursor,
hasMore = hasMore,
)
}
fun getGroupedCommentCountByApplicationFromId(applicationFormId: UUID): Map<UUID, Long> {
applicationFormRepository.findById(applicationFormId).orElse(null)
return commentRepository
.countByApplicationFormIdGroupByFormElementId(applicationFormId)
.associate { it.formElementId to it.commentCount }
}
fun updateComment(
id: UUID,
commentDto: CommentDto,
): Comment {
val existing = getCommentById(id)
val updated = commentMapper.applyUpdate(existing, commentDto)
return try {
commentRepository.save(updated)
} catch (e: Exception) {
throw CommentNotUpdatedException(e, id)
}
}
fun deleteCommentByID(id: UUID) {
try {
commentRepository.deleteById(id)
} catch (e: Exception) {
throw CommentNotDeletedException(e)
}
}
/**
* Extracts plain text from TipTap/ProseMirror JSON content.
* TipTap stores content as JSON like: {"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Hello"}]}]}
*/
private fun extractPlainTextFromTipTap(jsonContent: String): String =
try {
val rootNode = objectMapper.readTree(jsonContent)
val textBuilder = StringBuilder()
extractTextRecursively(rootNode, textBuilder)
textBuilder.toString().trim()
} catch (e: Exception) {
// If parsing fails, return the original content (might be plain text)
jsonContent
}
private fun extractTextRecursively(
node: JsonNode,
builder: StringBuilder,
) {
when {
node.has("text") -> {
builder.append(node.get("text").asText())
}
node.has("content") -> {
val content = node.get("content")
if (content.isArray) {
content.forEachIndexed { index, child ->
extractTextRecursively(child, builder)
// Add newline between paragraphs
if (child.has("type") &&
child.get("type").asText() == "paragraph" &&
index < content.size() - 1
) {
builder.append("\n")
}
}
}
}
}
}
}

View File

@@ -1,11 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.config
import com.fasterxml.jackson.databind.Module
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
@Configuration
class JacksonConfig {
@Bean
fun visibilityConditionJacksonModule(): Module = visibilityConditionModule()
}

View File

@@ -1,20 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.config
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.thymeleaf.templatemode.TemplateMode
import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver
@Configuration
class LatexThymeleafConfig {
@Bean
fun latexTemplateResolver(): ClassLoaderTemplateResolver =
ClassLoaderTemplateResolver().apply {
prefix = "templates/"
suffix = ".tex"
templateMode = TemplateMode.TEXT
characterEncoding = "UTF-8"
order = 1
checkExistence = true
}
}

View File

@@ -1,52 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.config
import com.betriebsratkanzlei.legalconsenthub.security.CustomJwtAuthenticationConverter
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.core.annotation.Order
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.annotation.web.invoke
import org.springframework.security.web.SecurityFilterChain
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
class SecurityConfig {
@Bean
@Order(1)
fun publicFilterChain(http: HttpSecurity): SecurityFilterChain {
http {
securityMatcher(
"/swagger-ui/**",
"/v3/**",
"/actuator/**",
"/notifications/unread/count",
)
csrf { disable() }
authorizeHttpRequests {
authorize(anyRequest, permitAll)
}
}
return http.build()
}
@Bean
@Order(2)
fun protectedFilterChain(
http: HttpSecurity,
customJwtAuthenticationConverter: CustomJwtAuthenticationConverter,
): SecurityFilterChain {
http {
csrf { disable() }
authorizeHttpRequests {
authorize(anyRequest, authenticated)
}
oauth2ResourceServer {
jwt { jwtAuthenticationConverter = customJwtAuthenticationConverter }
}
}
return http.build()
}
}

View File

@@ -1,34 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.config
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<*> =
PostgreSQLContainer(DockerImageName.parse("postgres:17-alpine"))
.withDatabaseName("legalconsenthub")
.withUsername("legalconsenthub")
.withPassword("legalconsenthub")
.withCreateContainerCmdModifier { cmd ->
cmd.withName("legalconsenthub-test-${System.currentTimeMillis()}")
// Comment this in to be able to connect to the database, needs to be commented our during tests
// cmd.withHostConfig(
// HostConfig().apply {
// this.withPortBindings(
// PortBinding(
// Ports.Binding.bindPort(5432),
// ExposedPort(5432),
// ),
// )
// },
// )
}.withReuse(true)
}

View File

@@ -1,99 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.config
import com.betriebsratkanzlei.legalconsenthub_api.model.VisibilityConditionNode
import com.betriebsratkanzlei.legalconsenthub_api.model.VisibilityConditionOperator
import com.betriebsratkanzlei.legalconsenthub_api.model.VisibilityConditionType
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.databind.DeserializationContext
import com.fasterxml.jackson.databind.JsonDeserializer
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.JsonSerializer
import com.fasterxml.jackson.databind.SerializerProvider
import com.fasterxml.jackson.databind.module.SimpleModule
class VisibilityConditionNodeDeserializer : JsonDeserializer<VisibilityConditionNode>() {
override fun deserialize(
p: JsonParser,
ctxt: DeserializationContext,
): VisibilityConditionNode {
val node = p.codec.readTree<JsonNode>(p)
val nodeType = node.get("nodeType")?.asText()
return if (nodeType == "GROUP") {
val groupOperator =
node.get("groupOperator")?.asText()?.let {
VisibilityConditionNode.GroupOperator.forValue(it)
}
val conditions =
node.get("conditions")?.map { childNode ->
val childParser = childNode.traverse(p.codec)
childParser.nextToken()
deserialize(childParser, ctxt)
} ?: emptyList()
VisibilityConditionNode(
nodeType = VisibilityConditionNode.NodeType.GROUP,
groupOperator = groupOperator,
conditions = conditions,
)
} else {
// Default to LEAF for backward compatibility
val sourceRef = node.get("sourceFormElementReference")?.asText()
val conditionType =
node.get("formElementConditionType")?.asText()?.let {
VisibilityConditionType.valueOf(it)
}
val expectedValue = node.get("formElementExpectedValue")?.asText()
val formElementOperator =
node.get("formElementOperator")?.asText()?.let {
VisibilityConditionOperator.valueOf(it)
}
VisibilityConditionNode(
nodeType = VisibilityConditionNode.NodeType.LEAF,
sourceFormElementReference = sourceRef,
formElementConditionType = conditionType,
formElementExpectedValue = expectedValue,
formElementOperator = formElementOperator,
)
}
}
}
class VisibilityConditionNodeSerializer : JsonSerializer<VisibilityConditionNode>() {
override fun serialize(
value: VisibilityConditionNode,
gen: JsonGenerator,
serializers: SerializerProvider,
) {
gen.writeStartObject()
gen.writeStringField("nodeType", (value.nodeType ?: VisibilityConditionNode.NodeType.LEAF).value)
if (value.nodeType == VisibilityConditionNode.NodeType.GROUP) {
value.groupOperator?.let { gen.writeStringField("groupOperator", it.value) }
gen.writeArrayFieldStart("conditions")
value.conditions?.forEach { serialize(it, gen, serializers) }
gen.writeEndArray()
} else {
value.formElementConditionType?.let {
gen.writeStringField("formElementConditionType", it.value)
}
value.sourceFormElementReference?.let {
gen.writeStringField("sourceFormElementReference", it)
}
value.formElementExpectedValue?.let {
gen.writeStringField("formElementExpectedValue", it)
}
value.formElementOperator?.let {
gen.writeStringField("formElementOperator", it.value)
}
}
gen.writeEndObject()
}
}
fun visibilityConditionModule(): SimpleModule =
SimpleModule("VisibilityConditionModule").apply {
addDeserializer(VisibilityConditionNode::class.java, VisibilityConditionNodeDeserializer())
addSerializer(VisibilityConditionNode::class.java, VisibilityConditionNodeSerializer())
}

View File

@@ -1,23 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.contact
import com.betriebsratkanzlei.legalconsenthub_api.api.ContactApi
import com.betriebsratkanzlei.legalconsenthub_api.model.ContactMessageDto
import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.RestController
@RestController
class ContactController(
private val contactService: ContactService,
) : ContactApi {
@PreAuthorize(
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL', 'EMPLOYEE')",
)
override fun sendContactMessage(contactMessageDto: ContactMessageDto): ResponseEntity<Unit> {
contactService.sendContactMessage(
subject = contactMessageDto.subject,
message = contactMessageDto.message,
)
return ResponseEntity.noContent().build()
}
}

View File

@@ -1,58 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.contact
import com.betriebsratkanzlei.legalconsenthub.email.EmailService
import com.betriebsratkanzlei.legalconsenthub.user.UserService
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import org.thymeleaf.TemplateEngine
import org.thymeleaf.context.Context
@Service
class ContactService(
private val emailService: EmailService,
private val userService: UserService,
private val templateEngine: TemplateEngine,
) {
private val logger = LoggerFactory.getLogger(ContactService::class.java)
companion object {
private const val CONTACT_EMAIL = "kontakt@gremiumhub.de"
}
fun sendContactMessage(
subject: String,
message: String,
) {
val currentUser = userService.getCurrentUser()
val emailBody =
buildContactEmail(
senderName = currentUser.name,
senderEmail = currentUser.email ?: "Keine E-Mail angegeben",
subject = subject,
message = message,
)
emailService.sendEmail(
to = CONTACT_EMAIL,
subject = "Kontaktanfrage: $subject",
body = emailBody,
)
logger.info("Contact message sent from user ${currentUser.keycloakId} with subject: $subject")
}
private fun buildContactEmail(
senderName: String,
senderEmail: String,
subject: String,
message: String,
): String {
val context = Context()
context.setVariable("senderName", senderName)
context.setVariable("senderEmail", senderEmail)
context.setVariable("subject", subject)
context.setVariable("message", message)
return templateEngine.process("email/contact_message", context)
}
}

View File

@@ -1,10 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.email
import java.util.UUID
data class ApplicationFormCreatedEvent(
val applicationFormId: UUID,
val organizationId: String?,
val creatorName: String,
val formName: String,
)

View File

@@ -1,10 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.email
import java.util.UUID
data class ApplicationFormSubmittedEvent(
val applicationFormId: UUID,
val organizationId: String?,
val creatorName: String,
val formName: String,
)

View File

@@ -1,11 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.email
import java.util.UUID
data class ApplicationFormUpdatedEvent(
val applicationFormId: UUID,
val organizationId: String?,
val updaterName: String,
val formName: String,
val authorKeycloakId: String,
)

View File

@@ -1,12 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.email
import java.util.UUID
data class CommentAddedEvent(
val applicationFormId: UUID,
val organizationId: String?,
val commenterName: String,
val formName: String,
val authorKeycloakId: String,
val commentPreview: String,
)

View File

@@ -1,138 +0,0 @@
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,
)
}
}
@Async
@EventListener
fun handleApplicationFormUpdated(event: ApplicationFormUpdatedEvent) {
logger.info("Processing ApplicationFormUpdatedEvent for form: ${event.formName}")
val recipient =
userRepository
.findByKeycloakIdAndEmailOnFormUpdatedTrue(event.authorKeycloakId)
?.takeIf { !it.email.isNullOrBlank() }
if (recipient == null) {
logger.info("No recipient found for form updated event (author has email disabled or no email)")
return
}
logger.info("Sending form updated email to author: ${recipient.email}")
val subject = "Ihr Mitbestimmungsantrag wurde aktualisiert: ${event.formName}"
val body =
emailService.buildFormUpdatedEmail(
formName = event.formName,
updaterName = event.updaterName,
applicationFormId = event.applicationFormId,
)
emailService.sendEmail(
to = recipient.email!!,
subject = subject,
body = body,
)
}
@Async
@EventListener
fun handleCommentAdded(event: CommentAddedEvent) {
logger.info("Processing CommentAddedEvent for form: ${event.formName}")
val recipient =
userRepository
.findByKeycloakIdAndEmailOnCommentAddedTrue(event.authorKeycloakId)
?.takeIf { !it.email.isNullOrBlank() }
if (recipient == null) {
logger.info("No recipient found for comment added event (author has email disabled or no email)")
return
}
logger.info("Sending comment added email to author: ${recipient.email}")
val subject = "Neuer Kommentar zu Ihrem Antrag: ${event.formName}"
val body =
emailService.buildCommentAddedEmail(
formName = event.formName,
commenterName = event.commenterName,
applicationFormId = event.applicationFormId,
commentPreview = event.commentPreview,
)
emailService.sendEmail(
to = recipient.email!!,
subject = subject,
body = body,
)
}
}

View File

@@ -1,89 +0,0 @@
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)
}
fun buildFormUpdatedEmail(
formName: String,
updaterName: String,
applicationFormId: UUID,
): String {
val context = Context()
context.setVariable("formName", formName)
context.setVariable("updaterName", updaterName)
context.setVariable("applicationFormId", applicationFormId)
return templateEngine.process("email/form_updated", context)
}
fun buildCommentAddedEmail(
formName: String,
commenterName: String,
applicationFormId: UUID,
commentPreview: String,
): String {
val context = Context()
context.setVariable("formName", formName)
context.setVariable("commenterName", commenterName)
context.setVariable("applicationFormId", applicationFormId)
context.setVariable("commentPreview", commentPreview)
return templateEngine.process("email/comment_added", context)
}
}

View File

@@ -1,13 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.error
import com.betriebsratkanzlei.legalconsenthub_api.model.ApplicationFormStatus
import java.util.UUID
class ApplicationFormInvalidStateException(
val applicationFormId: UUID,
val currentState: ApplicationFormStatus,
val expectedState: ApplicationFormStatus,
val operation: String,
) : RuntimeException(
"Cannot $operation application form with ID $applicationFormId. Current state: $currentState, expected state: $expectedState",
)

View File

@@ -1,5 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.error
class ApplicationFormNotCreatedException(
e: Exception,
) : RuntimeException("Couldn't create application form", e)

View File

@@ -1,5 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.error
class ApplicationFormNotDeletedException(
e: Exception,
) : RuntimeException("Couldn't delete application form", e)

View File

@@ -1,7 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.error
import java.util.UUID
class ApplicationFormNotFoundException(
id: UUID,
) : RuntimeException("Couldn't find application form with ID: $id")

View File

@@ -1,8 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.error
import java.util.UUID
class ApplicationFormNotUpdatedException(
e: Exception,
id: UUID,
) : RuntimeException("Couldn't update application form with ID: $id", e)

View File

@@ -1,8 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.error
import java.util.UUID
class ApplicationFormVersionNotFoundException(
applicationFormId: UUID,
versionNumber: Int,
) : RuntimeException("Application form version $versionNumber for application form $applicationFormId not found")

View File

@@ -1,5 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.error
class CommentNotCreatedException(
e: Exception,
) : RuntimeException("Couldn't create comment", e)

View File

@@ -1,5 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.error
class CommentNotDeletedException(
e: Exception,
) : RuntimeException("Couldn't delete comment", e)

View File

@@ -1,7 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.error
import java.util.UUID
class CommentNotFoundException(
id: UUID,
) : RuntimeException("Couldn't find comment with ID: $id")

View File

@@ -1,8 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.error
import java.util.UUID
class CommentNotUpdatedException(
e: Exception,
id: UUID,
) : RuntimeException("Couldn't update comment with ID: $id", e)

View File

@@ -1,112 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.error
import com.betriebsratkanzlei.legalconsenthub_api.model.ProblemDetails
import org.slf4j.LoggerFactory
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.http.converter.HttpMessageNotReadableException
import org.springframework.web.bind.annotation.ControllerAdvice
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.ResponseBody
import org.springframework.web.bind.annotation.ResponseStatus
import java.net.URI
@ControllerAdvice
class ExceptionHandler {
var logger = LoggerFactory.getLogger(ExceptionHandler::class.java)
@ResponseBody
@ExceptionHandler(ApplicationFormNotFoundException::class, UserNotFoundException::class)
@ResponseStatus(HttpStatus.NOT_FOUND)
fun handleNotFoundError(e: Exception): ResponseEntity<ProblemDetails> {
logger.warn(e.message, e)
return ResponseEntity
.status(HttpStatus.NOT_FOUND)
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
.body(
ProblemDetails(
title = "Not Found",
status = HttpStatus.NOT_FOUND.value(),
type = URI.create("about:blank"),
detail = e.message ?: "Something went wrong",
),
)
}
@ResponseBody
@ExceptionHandler(ApplicationFormInvalidStateException::class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
fun handleInvalidStateError(e: ApplicationFormInvalidStateException): ResponseEntity<ProblemDetails> {
logger.warn(e.message, e)
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
.body(
ProblemDetails(
title = "Invalid State",
status = HttpStatus.BAD_REQUEST.value(),
type = URI.create("about:blank"),
detail = e.message ?: "Operation not allowed in current state",
),
)
}
@ResponseBody
@ExceptionHandler(HttpMessageNotReadableException::class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
fun handleHttpMessageNotReadableException(e: HttpMessageNotReadableException): ResponseEntity<ProblemDetails> {
logger.warn("Failed to read HTTP message", e)
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
.body(
ProblemDetails(
title = "Bad Request",
status = HttpStatus.BAD_REQUEST.value(),
type = URI.create("about:blank"),
detail = e.message ?: "Invalid request body",
),
)
}
@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
@ExceptionHandler(
ApplicationFormNotCreatedException::class,
ApplicationFormNotUpdatedException::class,
ApplicationFormNotDeletedException::class,
)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
fun handleInternalServerError(e: Exception): ResponseEntity<ProblemDetails> {
logger.warn(e.message, e)
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
.body(
ProblemDetails(
title = "Internal Server Error",
status = HttpStatus.INTERNAL_SERVER_ERROR.value(),
type = URI.create("about:blank"),
detail = e.message ?: "Something went wrong",
),
)
}
}

View File

@@ -1,8 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.error
import java.util.UUID
class FileNotFoundException(
id: UUID,
message: String = "File not found: $id",
) : RuntimeException(message)

View File

@@ -1,6 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.error
class FileStorageException(
message: String,
cause: Throwable? = null,
) : RuntimeException(message, cause)

View File

@@ -1,5 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.error
class FileTooLargeException(
message: String,
) : RuntimeException(message)

View File

@@ -1,7 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.error
import java.util.UUID
class FormElementNotFoundException(
id: UUID,
) : RuntimeException("Couldn't find form element with ID: $id")

View File

@@ -1,7 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.error
import java.util.UUID
class FormElementSectionNotFoundException(
id: UUID,
) : RuntimeException("Couldn't find form element section with ID: $id")

View File

@@ -1,5 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.error
class UnsupportedFileTypeException(
message: String,
) : RuntimeException(message)

View File

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

View File

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

View File

@@ -1,125 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.file
import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotFoundException
import com.betriebsratkanzlei.legalconsenthub.error.FileNotFoundException
import com.betriebsratkanzlei.legalconsenthub.error.FileTooLargeException
import com.betriebsratkanzlei.legalconsenthub.error.UnsupportedFileTypeException
import com.betriebsratkanzlei.legalconsenthub_api.api.FileApi
import com.betriebsratkanzlei.legalconsenthub_api.model.AssociateFilesWithApplicationFormRequest
import com.betriebsratkanzlei.legalconsenthub_api.model.UploadedFileDto
import org.springframework.core.io.ByteArrayResource
import org.springframework.core.io.Resource
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile
import java.util.UUID
@RestController
class FileController(
private val fileService: FileService,
private val fileMapper: FileMapper,
) : FileApi {
@PreAuthorize(
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL', 'EMPLOYEE')",
)
override fun uploadFile(
file: MultipartFile,
formElementReference: String,
organizationId: String?,
applicationFormId: UUID?,
): ResponseEntity<UploadedFileDto> =
try {
val uploadedFile = fileService.uploadFile(file, applicationFormId, formElementReference, organizationId)
ResponseEntity
.status(HttpStatus.CREATED)
.body(fileMapper.toDto(uploadedFile))
} catch (e: FileTooLargeException) {
ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE).build()
} catch (e: UnsupportedFileTypeException) {
ResponseEntity.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE).build()
}
@PreAuthorize(
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL', 'EMPLOYEE')",
)
override fun getFileById(id: UUID): ResponseEntity<UploadedFileDto> =
try {
val uploadedFile = fileService.getFile(id)
ResponseEntity.ok(fileMapper.toDto(uploadedFile))
} catch (e: FileNotFoundException) {
ResponseEntity.notFound().build()
}
@PreAuthorize(
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL', 'EMPLOYEE')",
)
override fun downloadFileContent(
id: UUID,
inline: Boolean,
): ResponseEntity<Resource> =
try {
val (uploadedFile, bytes) = fileService.downloadFile(id)
val resource = ByteArrayResource(bytes)
val disposition = if (inline) "inline" else "attachment"
ResponseEntity
.ok()
.contentType(MediaType.parseMediaType(uploadedFile.mimeType))
.header(
HttpHeaders.CONTENT_DISPOSITION,
"$disposition; filename=\"${uploadedFile.originalFilename}\"",
).body(resource)
} catch (e: FileNotFoundException) {
ResponseEntity.notFound().build()
}
@PreAuthorize(
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL', 'EMPLOYEE')",
)
override fun getFilesByApplicationForm(
applicationFormId: UUID,
formElementReference: String?,
): ResponseEntity<List<UploadedFileDto>> {
val files =
if (formElementReference != null) {
fileService.getFilesByElement(applicationFormId, formElementReference)
} else {
fileService.getFilesByApplicationForm(applicationFormId)
}
return ResponseEntity.ok(files.map { fileMapper.toDto(it) })
}
@PreAuthorize(
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL')",
)
override fun deleteFile(id: UUID): ResponseEntity<Unit> =
try {
fileService.deleteFile(id)
ResponseEntity.noContent().build()
} catch (e: FileNotFoundException) {
ResponseEntity.notFound().build()
}
@PreAuthorize(
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL', 'EMPLOYEE')",
)
override fun associateFilesWithApplicationForm(
applicationFormId: UUID,
associateFilesWithApplicationFormRequest: AssociateFilesWithApplicationFormRequest,
): ResponseEntity<Unit> =
try {
fileService.associateTemporaryFiles(
associateFilesWithApplicationFormRequest.fileIds,
applicationFormId,
)
ResponseEntity.noContent().build()
} catch (e: FileNotFoundException) {
ResponseEntity.notFound().build()
} catch (e: ApplicationFormNotFoundException) {
ResponseEntity.notFound().build()
}
}

View File

@@ -1,25 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.file
import com.betriebsratkanzlei.legalconsenthub.user.UserMapper
import com.betriebsratkanzlei.legalconsenthub_api.model.UploadedFileDto
import org.springframework.stereotype.Component
import java.time.Instant
@Component
class FileMapper(
private val userMapper: UserMapper,
) {
fun toDto(uploadedFile: UploadedFile): UploadedFileDto =
UploadedFileDto(
id = uploadedFile.id ?: throw IllegalStateException("File ID must not be null"),
filename = uploadedFile.filename,
originalFilename = uploadedFile.originalFilename,
propertySize = uploadedFile.size,
mimeType = uploadedFile.mimeType,
organizationId = uploadedFile.organizationId,
applicationFormId = uploadedFile.applicationForm?.id,
formElementReference = uploadedFile.formElementReference,
uploadedAt = uploadedFile.uploadedAt ?: Instant.now(),
uploadedBy = uploadedFile.uploadedBy?.let { userMapper.toUserDto(it) },
)
}

View File

@@ -1,23 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.file
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import org.springframework.stereotype.Repository
import java.time.Instant
import java.util.UUID
@Repository
interface FileRepository : JpaRepository<UploadedFile, UUID> {
fun findByApplicationFormId(applicationFormId: UUID): List<UploadedFile>
fun findByApplicationFormIdAndFormElementReference(
applicationFormId: UUID,
formElementReference: String,
): List<UploadedFile>
@Query("SELECT f FROM UploadedFile f WHERE f.isTemporary = true AND f.uploadedAt < :cutoffDate")
fun findTemporaryFilesOlderThan(
@Param("cutoffDate") cutoffDate: Instant,
): List<UploadedFile>
}

View File

@@ -1,376 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.file
import com.betriebsratkanzlei.legalconsenthub.application_form.ApplicationForm
import com.betriebsratkanzlei.legalconsenthub.application_form.ApplicationFormRepository
import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotFoundException
import com.betriebsratkanzlei.legalconsenthub.error.FileNotFoundException
import com.betriebsratkanzlei.legalconsenthub.error.FileStorageException
import com.betriebsratkanzlei.legalconsenthub.error.FileTooLargeException
import com.betriebsratkanzlei.legalconsenthub.error.UnsupportedFileTypeException
import com.betriebsratkanzlei.legalconsenthub.security.CustomJwtTokenPrincipal
import com.betriebsratkanzlei.legalconsenthub.user.UserRepository
import org.apache.tika.Tika
import org.slf4j.LoggerFactory
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.multipart.MultipartFile
import java.util.UUID
@Service
class FileService(
private val fileRepository: FileRepository,
private val fileStorage: FileStorage,
private val applicationFormRepository: ApplicationFormRepository,
private val userRepository: UserRepository,
) {
private val logger = LoggerFactory.getLogger(FileService::class.java)
private val tika = Tika()
companion object {
private const val MAX_FILE_SIZE = 10 * 1024 * 1024L // 10MB in bytes
private val ALLOWED_MIME_TYPES =
setOf(
"application/pdf",
"application/x-pdf", // PDF variant detected by some tools
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", // DOCX
"application/msword", // DOC
"application/vnd.oasis.opendocument.text", // ODT
"image/jpeg",
"image/png",
"application/zip",
"application/x-zip-compressed",
)
}
fun uploadFile(
file: MultipartFile,
applicationFormId: UUID?,
formElementReference: String,
organizationId: String?,
): UploadedFile {
if (file.size > MAX_FILE_SIZE) {
throw FileTooLargeException(
"File size ${file.size} bytes exceeds maximum allowed size $MAX_FILE_SIZE bytes",
)
}
val detectedMimeType =
try {
tika.detect(file.inputStream)
} catch (e: Exception) {
logger.error("Failed to detect MIME type for file: ${file.originalFilename}", e)
throw UnsupportedFileTypeException("Failed to detect file type")
}
if (detectedMimeType !in ALLOWED_MIME_TYPES) {
logger.warn("Rejected file '${file.originalFilename}' with detected MIME type: $detectedMimeType")
throw UnsupportedFileTypeException("File type $detectedMimeType is not allowed")
}
val sanitizedFilename = sanitizeFilename(file.originalFilename ?: "unnamed")
val uniqueFilename = "${UUID.randomUUID()}_$sanitizedFilename"
// Get application form if ID provided (null for temporary uploads)
val applicationForm =
applicationFormId?.let {
applicationFormRepository
.findById(it)
.orElseThrow { ApplicationFormNotFoundException(it) }
}
val isTemporary = applicationFormId == null
val principal = SecurityContextHolder.getContext().authentication.principal as? CustomJwtTokenPrincipal
val currentUser = principal?.id?.let { userRepository.findById(it).orElse(null) }
val storageKey =
FileStorageKey(
organizationId = organizationId,
applicationFormId = applicationFormId,
formElementReference = formElementReference,
filename = uniqueFilename,
)
val storagePath =
try {
val fileBytes = file.bytes
fileStorage.store(storageKey, fileBytes)
} catch (e: Exception) {
logger.error("Failed to store file: ${file.originalFilename}", e)
throw FileStorageException("Failed to store file", e)
}
val uploadedFile =
UploadedFile(
filename = uniqueFilename,
originalFilename = sanitizedFilename,
size = file.size,
mimeType = detectedMimeType,
organizationId = organizationId,
applicationForm = applicationForm,
formElementReference = formElementReference,
storagePath = storagePath,
uploadedBy = currentUser,
isTemporary = isTemporary,
)
return try {
fileRepository.save(uploadedFile)
} catch (e: Exception) {
// Cleanup storage if database save fails
try {
fileStorage.delete(storageKey)
} catch (cleanupException: Exception) {
logger.error("Failed to cleanup file after database save failure", cleanupException)
}
logger.error("Failed to save file metadata to database", e)
throw FileStorageException("Failed to save file metadata", e)
}
}
fun getFile(id: UUID): UploadedFile =
fileRepository
.findById(id)
.orElseThrow { FileNotFoundException(id) }
fun downloadFile(id: UUID): Pair<UploadedFile, ByteArray> {
val uploadedFile = getFile(id)
val storageKey =
FileStorageKey(
organizationId = uploadedFile.organizationId,
applicationFormId = uploadedFile.applicationForm?.id,
formElementReference = uploadedFile.formElementReference,
filename = uploadedFile.filename,
)
val bytes =
fileStorage.retrieve(storageKey)
?: throw FileNotFoundException(id, "File not found in storage")
return Pair(uploadedFile, bytes)
}
fun getFilesByApplicationForm(applicationFormId: UUID): List<UploadedFile> =
fileRepository.findByApplicationFormId(applicationFormId)
fun getFilesByElement(
applicationFormId: UUID,
formElementReference: String,
): List<UploadedFile> =
fileRepository.findByApplicationFormIdAndFormElementReference(
applicationFormId,
formElementReference,
)
fun deleteFile(id: UUID) {
val uploadedFile = getFile(id)
val storageKey =
FileStorageKey(
organizationId = uploadedFile.organizationId,
applicationFormId = uploadedFile.applicationForm?.id,
formElementReference = uploadedFile.formElementReference,
filename = uploadedFile.filename,
)
try {
fileStorage.delete(storageKey)
} catch (e: Exception) {
logger.error("Failed to delete file from storage: $id", e)
// Continue with database deletion even if storage deletion fails
}
try {
fileRepository.delete(uploadedFile)
} catch (e: Exception) {
logger.error("Failed to delete file from database: $id", e)
throw FileStorageException("Failed to delete file", e)
}
}
/**
* Associates temporary files with already existing application form.
*/
fun associateTemporaryFiles(
fileIds: List<UUID>,
applicationFormId: UUID,
) {
val applicationForm =
applicationFormRepository
.findById(applicationFormId)
.orElseThrow { ApplicationFormNotFoundException(applicationFormId) }
fileIds.forEach { fileId ->
val uploadedFile = getFile(fileId)
if (!uploadedFile.isTemporary) {
return@forEach
}
// Move file from temporary storage to application form storage
val oldStorageKey =
FileStorageKey(
organizationId = uploadedFile.organizationId,
applicationFormId = null,
formElementReference = uploadedFile.formElementReference,
filename = uploadedFile.filename,
)
val newStorageKey =
FileStorageKey(
organizationId = uploadedFile.organizationId,
applicationFormId = applicationFormId,
formElementReference = uploadedFile.formElementReference,
filename = uploadedFile.filename,
)
// Retrieve file from temporary location
val bytes =
fileStorage.retrieve(oldStorageKey)
?: throw FileNotFoundException(fileId, "Temporary file not found in storage")
// Store in new location
val newStoragePath = fileStorage.store(newStorageKey, bytes)
// Delete from old location
fileStorage.delete(oldStorageKey)
// Update database record
uploadedFile.applicationForm = applicationForm
uploadedFile.isTemporary = false
uploadedFile.storagePath = newStoragePath
fileRepository.save(uploadedFile)
}
}
/**
* Associates temporary files with a newly created application form atomically.
* Uses a copy-first pattern with compensating transactions for filesystem safety:
* 1. Copy files to new locations (reversible)
* 2. Update database records (within transaction)
* 3. Delete original files (cleanup)
*
* If any step fails, compensating transactions clean up copied files
* and the database transaction rolls back.
*
*/
@Transactional(rollbackFor = [Exception::class])
fun associateTemporaryFilesTransactional(
fileIds: List<UUID>,
applicationForm: ApplicationForm,
) {
if (fileIds.isEmpty()) {
return
}
// Track moved files for compensating transaction
data class MovedFile(
val oldKey: FileStorageKey,
val newKey: FileStorageKey,
val uploadedFile: UploadedFile,
val newStoragePath: String,
)
val movedFiles = mutableListOf<MovedFile>()
try {
fileIds.forEach { fileId ->
val uploadedFile =
fileRepository
.findById(fileId)
.orElseThrow { FileNotFoundException(fileId) }
if (!uploadedFile.isTemporary) {
logger.debug("Skipping non-temporary file: {}", fileId)
return@forEach
}
val oldStorageKey =
FileStorageKey(
organizationId = uploadedFile.organizationId,
applicationFormId = null,
formElementReference = uploadedFile.formElementReference,
filename = uploadedFile.filename,
)
val newStorageKey =
FileStorageKey(
organizationId = uploadedFile.organizationId,
applicationFormId = applicationForm.id!!,
formElementReference = uploadedFile.formElementReference,
filename = uploadedFile.filename,
)
// Step 1: Copy file to new location (don't delete yet)
val bytes =
fileStorage.retrieve(oldStorageKey)
?: throw FileNotFoundException(fileId, "Temporary file not found in storage")
val newStoragePath = fileStorage.store(newStorageKey, bytes)
movedFiles.add(MovedFile(oldStorageKey, newStorageKey, uploadedFile, newStoragePath))
// Step 2: Update database record (within transaction)
uploadedFile.applicationForm = applicationForm
uploadedFile.isTemporary = false
uploadedFile.storagePath = newStoragePath
fileRepository.save(uploadedFile)
}
// Step 3: All succeeded - delete old files (cleanup)
movedFiles.forEach { movedFile ->
try {
fileStorage.delete(movedFile.oldKey)
} catch (e: Exception) {
// Log but don't fail - cleanup scheduler will handle orphaned files
logger.warn("Failed to delete old temporary file: ${movedFile.oldKey.filename}", e)
}
}
logger.info("Successfully associated ${movedFiles.size} files with application form $applicationForm.id")
} catch (e: Exception) {
// Compensating transaction: cleanup copied files
movedFiles.forEach { movedFile ->
try {
fileStorage.delete(movedFile.newKey)
logger.debug("Cleaned up copied file: ${movedFile.newKey.filename}")
} catch (cleanupException: Exception) {
logger.error(
"Failed to cleanup copied file during rollback: ${movedFile.newKey.filename}",
cleanupException,
)
}
}
logger.error("Failed to associate temporary files with application form $applicationForm.id", e)
throw e
}
}
fun deleteTemporaryFilesOlderThan(days: Long) {
val cutoffDate =
java.time.Instant
.now()
.minus(days, java.time.temporal.ChronoUnit.DAYS)
val temporaryFiles = fileRepository.findTemporaryFilesOlderThan(cutoffDate)
logger.info("Found ${temporaryFiles.size} temporary files older than $days days")
temporaryFiles.forEach { file ->
try {
deleteFile(file.id!!)
logger.info("Deleted temporary file: ${file.id}")
} catch (e: Exception) {
logger.error("Failed to delete temporary file: ${file.id}", e)
}
}
}
private fun sanitizeFilename(filename: String): String {
// Remove path separators and limit length
return filename
.replace(Regex("[/\\\\]"), "_")
.replace(Regex("\\.\\./"), "_")
.take(255)
}
}

View File

@@ -1,29 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.file
import java.util.UUID
data class FileStorageKey(
val organizationId: String?,
val applicationFormId: UUID?,
val formElementReference: String,
val filename: String,
) {
fun toPathParts(): List<String> {
val orgId = organizationId ?: "global"
val formId = applicationFormId?.toString() ?: "temporary"
return listOf(orgId, formId, formElementReference, filename)
}
}
interface FileStorage {
fun store(
key: FileStorageKey,
bytes: ByteArray,
): String
fun retrieve(key: FileStorageKey): ByteArray?
fun delete(key: FileStorageKey): Boolean
fun exists(key: FileStorageKey): Boolean
}

View File

@@ -1,33 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.file
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.boot.context.properties.NestedConfigurationProperty
import org.springframework.context.annotation.Configuration
@Configuration
@EnableConfigurationProperties(FileStorageProperties::class)
class FileStorageConfiguration
@ConfigurationProperties(prefix = "legalconsenthub.file.storage")
data class FileStorageProperties(
@NestedConfigurationProperty
val filesystem: FileSystemProperties = FileSystemProperties(),
) {
data class FileSystemProperties(
/**
* Base directory for uploaded files. In development this defaults to a folder next to the backend code.
*
* Configure either via application.yaml:
* legalconsenthub:
* file:
* storage:
* filesystem:
* base-dir: /var/lib/legalconsenthub/files
*
* or via environment variable:
* LEGALCONSENTHUB_FILE_STORAGE_FILESYSTEM_BASE_DIR=/var/lib/legalconsenthub/files
*/
val baseDir: String = "./data/files",
)
}

View File

@@ -1,78 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.file
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Component
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.StandardCopyOption
import kotlin.io.path.exists
import kotlin.io.path.inputStream
@Component
class FileSystemFileStorage(
private val properties: FileStorageProperties,
) : FileStorage {
private val logger = LoggerFactory.getLogger(FileSystemFileStorage::class.java)
override fun store(
key: FileStorageKey,
bytes: ByteArray,
): String {
val targetPath = resolvePath(key)
Files.createDirectories(targetPath.parent)
val tmpFile = Files.createTempFile(targetPath.parent, targetPath.fileName.toString(), ".tmp")
try {
Files.write(tmpFile, bytes)
try {
Files.move(
tmpFile,
targetPath,
StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING,
)
} catch (e: Exception) {
logger.debug("Atomic move failed, falling back to non-atomic move: ${e.message}")
Files.move(
tmpFile,
targetPath,
StandardCopyOption.REPLACE_EXISTING,
)
}
} finally {
try {
Files.deleteIfExists(tmpFile)
} catch (_: Exception) {
// ignore
}
}
return targetPath.toString()
}
override fun retrieve(key: FileStorageKey): ByteArray? {
val path = resolvePath(key)
if (!path.exists()) return null
return path.inputStream().use { it.readBytes() }
}
override fun delete(key: FileStorageKey): Boolean {
val path = resolvePath(key)
return try {
Files.deleteIfExists(path)
} catch (e: Exception) {
logger.error("Failed to delete file at $path", e)
false
}
}
override fun exists(key: FileStorageKey): Boolean {
val path = resolvePath(key)
return path.exists()
}
private fun resolvePath(key: FileStorageKey): Path {
val baseDir = Path.of(properties.filesystem.baseDir)
return key.toPathParts().fold(baseDir) { acc, part -> acc.resolve(part) }
}
}

View File

@@ -1,27 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.file
import org.slf4j.LoggerFactory
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Component
@Component
class TemporaryFileCleanupScheduler(
private val fileService: FileService,
) {
private val logger = LoggerFactory.getLogger(TemporaryFileCleanupScheduler::class.java)
/**
* Delete temporary files older than 7 days
* Runs daily at 2 AM
*/
@Scheduled(cron = "0 0 2 * * *")
fun cleanupTemporaryFiles() {
logger.info("Starting temporary file cleanup job")
try {
fileService.deleteTemporaryFilesOlderThan(7)
logger.info("Temporary file cleanup job completed successfully")
} catch (e: Exception) {
logger.error("Temporary file cleanup job failed", e)
}
}
}

View File

@@ -1,50 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.file
import com.betriebsratkanzlei.legalconsenthub.application_form.ApplicationForm
import com.betriebsratkanzlei.legalconsenthub.user.User
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.EntityListeners
import jakarta.persistence.GeneratedValue
import jakarta.persistence.Id
import jakarta.persistence.JoinColumn
import jakarta.persistence.ManyToOne
import jakarta.persistence.Table
import org.springframework.data.annotation.CreatedDate
import org.springframework.data.jpa.domain.support.AuditingEntityListener
import java.time.Instant
import java.util.UUID
@Entity
@EntityListeners(AuditingEntityListener::class)
@Table(name = "uploaded_file")
class UploadedFile(
@Id
@GeneratedValue
var id: UUID? = null,
@Column(nullable = false)
var filename: String,
@Column(nullable = false)
var originalFilename: String,
@Column(nullable = false)
var size: Long,
@Column(nullable = false)
var mimeType: String,
@Column(nullable = true)
var organizationId: String? = null,
@ManyToOne
@JoinColumn(name = "application_form_id", nullable = true)
var applicationForm: ApplicationForm? = null,
@Column(nullable = false)
var formElementReference: String,
@Column(nullable = false)
var storagePath: String,
@ManyToOne
@JoinColumn(name = "uploaded_by_id", nullable = true)
var uploadedBy: User? = null,
@CreatedDate
@Column(nullable = false)
var uploadedAt: Instant? = null,
@Column(nullable = false)
var isTemporary: Boolean = false,
)

View File

@@ -1,56 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.form_element
import com.betriebsratkanzlei.legalconsenthub_api.model.FormElementType
import jakarta.persistence.AttributeOverride
import jakarta.persistence.AttributeOverrides
import jakarta.persistence.CollectionTable
import jakarta.persistence.Column
import jakarta.persistence.ElementCollection
import jakarta.persistence.Embedded
import jakarta.persistence.Entity
import jakarta.persistence.GeneratedValue
import jakarta.persistence.Id
import jakarta.persistence.JoinColumn
import jakarta.persistence.ManyToOne
import org.hibernate.annotations.JdbcTypeCode
import org.hibernate.type.SqlTypes
import java.util.UUID
@Entity
class FormElement(
@Id
@GeneratedValue
var id: UUID? = null,
var reference: String? = null,
var title: String? = null,
var description: String? = null,
@ElementCollection
@CollectionTable(name = "form_element_options", joinColumns = [JoinColumn(name = "form_element_id")])
var options: MutableList<FormOption> = mutableListOf(),
@Column(nullable = false)
var type: FormElementType,
@ManyToOne
@JoinColumn(name = "form_element_sub_section_id", nullable = false)
var formElementSubSection: FormElementSubSection? = null,
@JdbcTypeCode(SqlTypes.JSON)
@Column(columnDefinition = "jsonb")
var visibilityConditions: GroupCondition? = null,
@ElementCollection
@CollectionTable(name = "section_spawn_triggers", joinColumns = [JoinColumn(name = "form_element_id")])
var sectionSpawnTriggers: MutableList<SectionSpawnTrigger> = mutableListOf(),
var isClonable: Boolean = false,
@Embedded
@AttributeOverrides(
AttributeOverride(name = "sourceTableReference", column = Column(name = "row_preset_source_table_ref")),
AttributeOverride(
name = "filterCondition.sourceColumnIndex",
column = Column(name = "row_preset_filter_src_col_idx"),
),
AttributeOverride(
name = "filterCondition.expectedValue",
column = Column(name = "row_preset_filter_expected_val"),
),
AttributeOverride(name = "filterCondition.operator", column = Column(name = "row_preset_filter_operator")),
)
var tableRowPreset: TableRowPreset? = null,
)

View File

@@ -1,94 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.form_element
import com.betriebsratkanzlei.legalconsenthub_api.model.FormElementDto
import org.springframework.stereotype.Component
@Component
class FormElementMapper(
private val formOptionMapper: FormOptionMapper,
private val visibilityConditionMapper: FormElementVisibilityConditionMapper,
private val spawnTriggerMapper: SectionSpawnTriggerMapper,
private val tableRowPresetMapper: TableRowPresetMapper,
) {
fun toFormElementDto(formElement: FormElement): FormElementDto =
FormElementDto(
id = formElement.id,
reference = formElement.reference,
title = formElement.title,
description = formElement.description,
options = formElement.options.map { formOptionMapper.toFormOptionDto(it) },
type = formElement.type,
formElementSubSectionId =
formElement.formElementSubSection?.id
?: throw IllegalStateException("FormElementSubSection ID must not be null!"),
visibilityConditions =
formElement.visibilityConditions?.let {
visibilityConditionMapper.toGroupConditionDto(it)
},
sectionSpawnTriggers =
formElement.sectionSpawnTriggers.map {
spawnTriggerMapper.toSectionSpawnTriggerDto(it)
},
isClonable = formElement.isClonable,
tableRowPreset =
formElement.tableRowPreset?.let {
tableRowPresetMapper.toTableRowPresetDto(it)
},
)
fun toFormElement(
formElement: FormElementDto,
formElementSubSection: FormElementSubSection,
): FormElement =
FormElement(
id = formElement.id,
reference = formElement.reference,
title = formElement.title,
description = formElement.description,
options = formElement.options.map { formOptionMapper.toFormOption(it) }.toMutableList(),
type = formElement.type,
formElementSubSection = formElementSubSection,
visibilityConditions =
formElement.visibilityConditions?.let {
visibilityConditionMapper.toGroupCondition(it)
},
sectionSpawnTriggers =
formElement.sectionSpawnTriggers
?.map { spawnTriggerMapper.toSectionSpawnTrigger(it) }
?.toMutableList()
?: mutableListOf(),
isClonable = formElement.isClonable ?: false,
tableRowPreset =
formElement.tableRowPreset?.let {
tableRowPresetMapper.toTableRowPreset(it)
},
)
fun toNewFormElement(
formElement: FormElementDto,
formElementSubSection: FormElementSubSection,
): FormElement =
FormElement(
id = null,
reference = formElement.reference,
title = formElement.title,
description = formElement.description,
options = formElement.options.map { formOptionMapper.toFormOption(it) }.toMutableList(),
type = formElement.type,
formElementSubSection = formElementSubSection,
visibilityConditions =
formElement.visibilityConditions?.let {
visibilityConditionMapper.toGroupCondition(it)
},
sectionSpawnTriggers =
formElement.sectionSpawnTriggers
?.map { spawnTriggerMapper.toSectionSpawnTrigger(it) }
?.toMutableList()
?: mutableListOf(),
isClonable = formElement.isClonable ?: false,
tableRowPreset =
formElement.tableRowPreset?.let {
tableRowPresetMapper.toTableRowPreset(it)
},
)
}

View File

@@ -1,8 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.form_element
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
import java.util.UUID
@Repository
interface FormElementRepository : JpaRepository<FormElement, UUID>

View File

@@ -1,34 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.form_element
import com.betriebsratkanzlei.legalconsenthub.application_form.ApplicationForm
import jakarta.persistence.CascadeType
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.GeneratedValue
import jakarta.persistence.Id
import jakarta.persistence.JoinColumn
import jakarta.persistence.ManyToOne
import jakarta.persistence.OneToMany
import jakarta.persistence.OrderColumn
import java.util.UUID
@Entity
class FormElementSection(
@Id
@GeneratedValue
var id: UUID? = null,
@Column(nullable = false)
var title: String,
var shortTitle: String? = null,
var description: String? = null,
var isTemplate: Boolean = false,
var templateReference: String? = null,
var titleTemplate: String? = null,
var spawnedFromElementReference: String? = null,
@OneToMany(mappedBy = "formElementSection", cascade = [CascadeType.ALL], orphanRemoval = true)
@OrderColumn(name = "form_element_sub_section_order")
var formElementSubSections: MutableList<FormElementSubSection> = mutableListOf(),
@ManyToOne
@JoinColumn(name = "application_form_id", nullable = false)
var applicationForm: ApplicationForm? = null,
)

View File

@@ -1,75 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.form_element
import com.betriebsratkanzlei.legalconsenthub.application_form.ApplicationForm
import com.betriebsratkanzlei.legalconsenthub_api.model.FormElementSectionDto
import org.springframework.stereotype.Component
@Component
class FormElementSectionMapper(
private val formElementSubSectionMapper: FormElementSubSectionMapper,
) {
fun toFormElementSectionDto(formElementSection: FormElementSection): FormElementSectionDto =
FormElementSectionDto(
id = formElementSection.id,
title = formElementSection.title,
description = formElementSection.description,
shortTitle = formElementSection.shortTitle,
formElementSubSections =
formElementSection.formElementSubSections.map {
formElementSubSectionMapper.toFormElementSubSectionDto(it)
},
applicationFormId =
formElementSection.applicationForm?.id
?: throw IllegalStateException("ApplicationForm ID must not be null!"),
isTemplate = formElementSection.isTemplate,
templateReference = formElementSection.templateReference,
titleTemplate = formElementSection.titleTemplate,
spawnedFromElementReference = formElementSection.spawnedFromElementReference,
)
fun toFormElementSection(
formElementSection: FormElementSectionDto,
applicationForm: ApplicationForm,
): FormElementSection {
val section =
FormElementSection(
id = formElementSection.id,
title = formElementSection.title,
description = formElementSection.description,
shortTitle = formElementSection.shortTitle,
isTemplate = formElementSection.isTemplate ?: false,
templateReference = formElementSection.templateReference,
titleTemplate = formElementSection.titleTemplate,
spawnedFromElementReference = formElementSection.spawnedFromElementReference,
applicationForm = applicationForm,
)
section.formElementSubSections =
formElementSection.formElementSubSections
.map { formElementSubSectionMapper.toFormElementSubSection(it, section) }
.toMutableList()
return section
}
fun toNewFormElementSection(
formElementSection: FormElementSectionDto,
applicationForm: ApplicationForm,
): FormElementSection {
val section =
FormElementSection(
id = null,
title = formElementSection.title,
description = formElementSection.description,
shortTitle = formElementSection.shortTitle,
isTemplate = formElementSection.isTemplate ?: false,
templateReference = formElementSection.templateReference,
titleTemplate = formElementSection.titleTemplate,
spawnedFromElementReference = formElementSection.spawnedFromElementReference,
applicationForm = applicationForm,
)
section.formElementSubSections =
formElementSection.formElementSubSections
.map { formElementSubSectionMapper.toNewFormElementSubSection(it, section) }
.toMutableList()
return section
}
}

View File

@@ -1,8 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.form_element
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
import java.util.UUID
@Repository
interface FormElementSectionRepository : JpaRepository<FormElementSection, UUID>

View File

@@ -1,28 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.form_element
import jakarta.persistence.CascadeType
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.GeneratedValue
import jakarta.persistence.Id
import jakarta.persistence.JoinColumn
import jakarta.persistence.ManyToOne
import jakarta.persistence.OneToMany
import jakarta.persistence.OrderColumn
import java.util.UUID
@Entity
class FormElementSubSection(
@Id
@GeneratedValue
var id: UUID? = null,
@Column(nullable = false)
var title: String,
var subtitle: String? = null,
@OneToMany(mappedBy = "formElementSubSection", cascade = [CascadeType.ALL], orphanRemoval = true)
@OrderColumn(name = "form_element_order")
var formElements: MutableList<FormElement> = mutableListOf(),
@ManyToOne
@JoinColumn(name = "form_element_section_id", nullable = false)
var formElementSection: FormElementSection? = null,
)

View File

@@ -1,56 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.form_element
import com.betriebsratkanzlei.legalconsenthub_api.model.FormElementSubSectionDto
import org.springframework.stereotype.Component
@Component
class FormElementSubSectionMapper(
private val formElementMapper: FormElementMapper,
) {
fun toFormElementSubSectionDto(formElementSubSection: FormElementSubSection): FormElementSubSectionDto =
FormElementSubSectionDto(
id = formElementSubSection.id,
title = formElementSubSection.title,
subtitle = formElementSubSection.subtitle,
formElements = formElementSubSection.formElements.map { formElementMapper.toFormElementDto(it) },
formElementSectionId =
formElementSubSection.formElementSection?.id
?: throw IllegalStateException("FormElementSection ID must not be null!"),
)
fun toFormElementSubSection(
formElementSubSection: FormElementSubSectionDto,
formElementSection: FormElementSection,
): FormElementSubSection {
val subsection =
FormElementSubSection(
id = formElementSubSection.id,
title = formElementSubSection.title,
subtitle = formElementSubSection.subtitle,
formElementSection = formElementSection,
)
subsection.formElements =
formElementSubSection.formElements
.map { formElementMapper.toFormElement(it, subsection) }
.toMutableList()
return subsection
}
fun toNewFormElementSubSection(
formElementSubSection: FormElementSubSectionDto,
formElementSection: FormElementSection,
): FormElementSubSection {
val subsection =
FormElementSubSection(
id = null,
title = formElementSubSection.title,
subtitle = formElementSubSection.subtitle,
formElementSection = formElementSection,
)
subsection.formElements =
formElementSubSection.formElements
.map { formElementMapper.toNewFormElement(it, subsection) }
.toMutableList()
return subsection
}
}

View File

@@ -1,120 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.form_element
import org.springframework.stereotype.Component
import com.betriebsratkanzlei.legalconsenthub_api.model.VisibilityConditionGroup as VisibilityConditionGroupDto
import com.betriebsratkanzlei.legalconsenthub_api.model.VisibilityConditionNode as VisibilityConditionNodeDto
import com.betriebsratkanzlei.legalconsenthub_api.model.VisibilityConditionOperator as VisibilityConditionOperatorDto
import com.betriebsratkanzlei.legalconsenthub_api.model.VisibilityConditionType as VisibilityConditionTypeDto
@Component
class FormElementVisibilityConditionMapper {
fun toGroupConditionDto(group: GroupCondition): VisibilityConditionGroupDto =
VisibilityConditionGroupDto(
operator = toGroupOperatorDto(group.operator),
conditions = group.conditions.map { toNodeDto(it) },
)
fun toGroupCondition(groupDto: VisibilityConditionGroupDto): GroupCondition =
GroupCondition(
operator = toGroupOperator(groupDto.operator ?: VisibilityConditionGroupDto.Operator.AND),
conditions = groupDto.conditions?.map { toNode(it) } ?: emptyList(),
)
private fun toNodeDto(node: VisibilityConditionNode): VisibilityConditionNodeDto =
when (node) {
is LeafCondition ->
VisibilityConditionNodeDto(
nodeType = VisibilityConditionNodeDto.NodeType.LEAF,
sourceFormElementReference = node.sourceFormElementReference,
formElementConditionType = node.formElementConditionType?.let { toVisibilityConditionTypeDto(it) },
formElementExpectedValue = node.formElementExpectedValue,
formElementOperator = toVisibilityConditionOperatorDto(node.formElementOperator),
)
is GroupCondition ->
VisibilityConditionNodeDto(
nodeType = VisibilityConditionNodeDto.NodeType.GROUP,
groupOperator = toNodeGroupOperatorDto(node.operator),
conditions = node.conditions.map { toNodeDto(it) },
)
}
private fun toNode(nodeDto: VisibilityConditionNodeDto): VisibilityConditionNode =
when (nodeDto.nodeType) {
VisibilityConditionNodeDto.NodeType.GROUP ->
GroupCondition(
operator = toGroupOperatorFromNode(nodeDto.groupOperator),
conditions = nodeDto.conditions?.map { toNode(it) } ?: emptyList(),
)
VisibilityConditionNodeDto.NodeType.LEAF, null ->
LeafCondition(
formElementConditionType = nodeDto.formElementConditionType?.let { toVisibilityConditionType(it) },
sourceFormElementReference = nodeDto.sourceFormElementReference ?: "",
formElementExpectedValue = nodeDto.formElementExpectedValue,
formElementOperator =
nodeDto.formElementOperator?.let { toVisibilityConditionOperator(it) }
?: VisibilityConditionOperator.EQUALS,
)
}
private fun toGroupOperatorDto(op: GroupOperator): VisibilityConditionGroupDto.Operator =
when (op) {
GroupOperator.AND -> VisibilityConditionGroupDto.Operator.AND
GroupOperator.OR -> VisibilityConditionGroupDto.Operator.OR
}
private fun toGroupOperator(op: VisibilityConditionGroupDto.Operator): GroupOperator =
when (op) {
VisibilityConditionGroupDto.Operator.AND -> GroupOperator.AND
VisibilityConditionGroupDto.Operator.OR -> GroupOperator.OR
}
private fun toNodeGroupOperatorDto(op: GroupOperator): VisibilityConditionNodeDto.GroupOperator =
when (op) {
GroupOperator.AND -> VisibilityConditionNodeDto.GroupOperator.AND
GroupOperator.OR -> VisibilityConditionNodeDto.GroupOperator.OR
}
private fun toGroupOperatorFromNode(op: VisibilityConditionNodeDto.GroupOperator?): GroupOperator =
when (op) {
VisibilityConditionNodeDto.GroupOperator.AND -> GroupOperator.AND
VisibilityConditionNodeDto.GroupOperator.OR -> GroupOperator.OR
null -> GroupOperator.AND
}
private fun toVisibilityConditionTypeDto(type: VisibilityConditionType): VisibilityConditionTypeDto =
when (type) {
VisibilityConditionType.SHOW -> VisibilityConditionTypeDto.SHOW
VisibilityConditionType.HIDE -> VisibilityConditionTypeDto.HIDE
}
private fun toVisibilityConditionType(typeDto: VisibilityConditionTypeDto): VisibilityConditionType =
when (typeDto) {
VisibilityConditionTypeDto.SHOW -> VisibilityConditionType.SHOW
VisibilityConditionTypeDto.HIDE -> VisibilityConditionType.HIDE
}
private fun toVisibilityConditionOperatorDto(
operator: VisibilityConditionOperator,
): VisibilityConditionOperatorDto =
when (operator) {
VisibilityConditionOperator.EQUALS -> VisibilityConditionOperatorDto.EQUALS
VisibilityConditionOperator.NOT_EQUALS -> VisibilityConditionOperatorDto.NOT_EQUALS
VisibilityConditionOperator.IS_EMPTY -> VisibilityConditionOperatorDto.IS_EMPTY
VisibilityConditionOperator.IS_NOT_EMPTY -> VisibilityConditionOperatorDto.IS_NOT_EMPTY
VisibilityConditionOperator.CONTAINS -> VisibilityConditionOperatorDto.CONTAINS
VisibilityConditionOperator.NOT_CONTAINS -> VisibilityConditionOperatorDto.NOT_CONTAINS
}
private fun toVisibilityConditionOperator(
operatorDto: VisibilityConditionOperatorDto,
): VisibilityConditionOperator =
when (operatorDto) {
VisibilityConditionOperatorDto.EQUALS -> VisibilityConditionOperator.EQUALS
VisibilityConditionOperatorDto.NOT_EQUALS -> VisibilityConditionOperator.NOT_EQUALS
VisibilityConditionOperatorDto.IS_EMPTY -> VisibilityConditionOperator.IS_EMPTY
VisibilityConditionOperatorDto.IS_NOT_EMPTY -> VisibilityConditionOperator.IS_NOT_EMPTY
VisibilityConditionOperatorDto.CONTAINS -> VisibilityConditionOperator.CONTAINS
VisibilityConditionOperatorDto.NOT_CONTAINS -> VisibilityConditionOperator.NOT_CONTAINS
}
}

View File

@@ -1,55 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.form_element
import com.betriebsratkanzlei.legalconsenthub_api.model.EmployeeDataCategory
import com.betriebsratkanzlei.legalconsenthub_api.model.ProcessingPurpose
import jakarta.persistence.AttributeOverride
import jakarta.persistence.AttributeOverrides
import jakarta.persistence.Column
import jakarta.persistence.Embeddable
import jakarta.persistence.Embedded
import org.hibernate.annotations.JdbcTypeCode
import org.hibernate.type.SqlTypes
@Embeddable
class FormOption(
@Column(nullable = false, name = "option_value", columnDefinition = "TEXT")
var value: String,
@Column(nullable = false)
var label: String,
@Column(nullable = false)
var processingPurpose: ProcessingPurpose,
@Column(nullable = false)
var employeeDataCategory: EmployeeDataCategory,
@Embedded
@AttributeOverrides(
AttributeOverride(name = "sourceTableReference", column = Column(name = "col_config_source_table_ref")),
AttributeOverride(name = "sourceColumnIndex", column = Column(name = "col_config_source_col_idx")),
AttributeOverride(
name = "filterCondition.sourceColumnIndex",
column = Column(name = "col_config_filter_src_col_idx"),
),
AttributeOverride(
name = "filterCondition.expectedValue",
column = Column(name = "col_config_filter_expected_val"),
),
AttributeOverride(name = "filterCondition.operator", column = Column(name = "col_config_filter_operator")),
AttributeOverride(name = "isReadOnly", column = Column(name = "col_config_is_read_only")),
AttributeOverride(name = "isCheckbox", column = Column(name = "col_config_is_checkbox")),
AttributeOverride(
name = "readOnlyDefaultValue",
column = Column(name = "col_config_read_only_default_value"),
),
AttributeOverride(
name = "readOnlyConditions",
column = Column(name = "col_config_read_only_conditions", columnDefinition = "jsonb"),
),
AttributeOverride(
name = "rowVisibilityCondition",
column = Column(name = "col_config_row_visibility_condition", columnDefinition = "jsonb"),
),
)
var columnConfig: TableColumnConfig? = null,
@JdbcTypeCode(SqlTypes.JSON)
@Column(columnDefinition = "jsonb", name = "visibility_conditions")
var visibilityConditions: GroupCondition? = null,
)

View File

@@ -1,36 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.form_element
import com.betriebsratkanzlei.legalconsenthub_api.model.FormOptionDto
import org.springframework.stereotype.Component
@Component
class FormOptionMapper(
private val columnConfigMapper: TableColumnConfigMapper,
private val visibilityConditionMapper: FormElementVisibilityConditionMapper,
) {
fun toFormOptionDto(formOption: FormOption): FormOptionDto =
FormOptionDto(
value = formOption.value,
label = formOption.label,
processingPurpose = formOption.processingPurpose,
employeeDataCategory = formOption.employeeDataCategory,
columnConfig = formOption.columnConfig?.let { columnConfigMapper.toTableColumnConfigDto(it) },
visibilityConditions =
formOption.visibilityConditions?.let {
visibilityConditionMapper.toGroupConditionDto(it)
},
)
fun toFormOption(formOptionDto: FormOptionDto): FormOption =
FormOption(
value = formOptionDto.value,
label = formOptionDto.label,
processingPurpose = formOptionDto.processingPurpose,
employeeDataCategory = formOptionDto.employeeDataCategory,
columnConfig = formOptionDto.columnConfig?.let { columnConfigMapper.toTableColumnConfig(it) },
visibilityConditions =
formOptionDto.visibilityConditions?.let {
visibilityConditionMapper.toGroupCondition(it)
},
)
}

View File

@@ -1,15 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.form_element
import jakarta.persistence.Embeddable
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
@Embeddable
data class SectionSpawnTrigger(
val templateReference: String,
@Enumerated(EnumType.STRING)
val sectionSpawnConditionType: VisibilityConditionType,
val sectionSpawnExpectedValue: String? = null,
@Enumerated(EnumType.STRING)
val sectionSpawnOperator: VisibilityConditionOperator = VisibilityConditionOperator.EQUALS,
)

View File

@@ -1,61 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.form_element
import com.betriebsratkanzlei.legalconsenthub_api.model.SectionSpawnTriggerDto
import org.springframework.stereotype.Component
import com.betriebsratkanzlei.legalconsenthub_api.model.VisibilityConditionOperator as VisibilityConditionOperatorDto
import com.betriebsratkanzlei.legalconsenthub_api.model.VisibilityConditionType as VisibilityConditionTypeDto
@Component
class SectionSpawnTriggerMapper {
fun toSectionSpawnTriggerDto(trigger: SectionSpawnTrigger): SectionSpawnTriggerDto =
SectionSpawnTriggerDto(
templateReference = trigger.templateReference,
sectionSpawnConditionType = toVisibilityConditionTypeDto(trigger.sectionSpawnConditionType),
sectionSpawnExpectedValue = trigger.sectionSpawnExpectedValue,
sectionSpawnOperator = toVisibilityConditionOperatorDto(trigger.sectionSpawnOperator),
)
fun toSectionSpawnTrigger(triggerDto: SectionSpawnTriggerDto): SectionSpawnTrigger =
SectionSpawnTrigger(
templateReference = triggerDto.templateReference,
sectionSpawnConditionType = toVisibilityConditionType(triggerDto.sectionSpawnConditionType),
sectionSpawnExpectedValue = triggerDto.sectionSpawnExpectedValue,
sectionSpawnOperator = toVisibilityConditionOperator(triggerDto.sectionSpawnOperator),
)
private fun toVisibilityConditionTypeDto(type: VisibilityConditionType): VisibilityConditionTypeDto =
when (type) {
VisibilityConditionType.SHOW -> VisibilityConditionTypeDto.SHOW
VisibilityConditionType.HIDE -> VisibilityConditionTypeDto.HIDE
}
private fun toVisibilityConditionType(typeDto: VisibilityConditionTypeDto): VisibilityConditionType =
when (typeDto) {
VisibilityConditionTypeDto.SHOW -> VisibilityConditionType.SHOW
VisibilityConditionTypeDto.HIDE -> VisibilityConditionType.HIDE
}
private fun toVisibilityConditionOperatorDto(
operator: VisibilityConditionOperator,
): VisibilityConditionOperatorDto =
when (operator) {
VisibilityConditionOperator.EQUALS -> VisibilityConditionOperatorDto.EQUALS
VisibilityConditionOperator.NOT_EQUALS -> VisibilityConditionOperatorDto.NOT_EQUALS
VisibilityConditionOperator.IS_EMPTY -> VisibilityConditionOperatorDto.IS_EMPTY
VisibilityConditionOperator.IS_NOT_EMPTY -> VisibilityConditionOperatorDto.IS_NOT_EMPTY
VisibilityConditionOperator.CONTAINS -> VisibilityConditionOperatorDto.CONTAINS
VisibilityConditionOperator.NOT_CONTAINS -> VisibilityConditionOperatorDto.NOT_CONTAINS
}
private fun toVisibilityConditionOperator(
operatorDto: VisibilityConditionOperatorDto,
): VisibilityConditionOperator =
when (operatorDto) {
VisibilityConditionOperatorDto.EQUALS -> VisibilityConditionOperator.EQUALS
VisibilityConditionOperatorDto.NOT_EQUALS -> VisibilityConditionOperator.NOT_EQUALS
VisibilityConditionOperatorDto.IS_EMPTY -> VisibilityConditionOperator.IS_EMPTY
VisibilityConditionOperatorDto.IS_NOT_EMPTY -> VisibilityConditionOperator.IS_NOT_EMPTY
VisibilityConditionOperatorDto.CONTAINS -> VisibilityConditionOperator.CONTAINS
VisibilityConditionOperatorDto.NOT_CONTAINS -> VisibilityConditionOperator.NOT_CONTAINS
}
}

View File

@@ -1,41 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.form_element
import jakarta.persistence.AttributeOverride
import jakarta.persistence.AttributeOverrides
import jakarta.persistence.Column
import jakarta.persistence.Embeddable
import jakarta.persistence.Embedded
import org.hibernate.annotations.JdbcTypeCode
import org.hibernate.type.SqlTypes
@Embeddable
data class TableColumnConfig(
val sourceTableReference: String? = null,
val sourceColumnIndex: Int? = null,
@Embedded
val filterCondition: TableColumnFilter? = null,
@Embedded
@AttributeOverrides(
AttributeOverride(name = "constraintTableReference", column = Column(name = "row_constraint_table_reference")),
AttributeOverride(name = "constraintKeyColumnIndex", column = Column(name = "row_constraint_key_column_index")),
AttributeOverride(
name = "constraintValueColumnIndex",
column = Column(name = "row_constraint_value_column_index"),
),
AttributeOverride(
name = "currentRowKeyColumnIndex",
column = Column(name = "row_constraint_current_row_key_column_index"),
),
)
val rowConstraint: TableRowConstraint? = null,
val isReadOnly: Boolean = false,
val isMultipleAllowed: Boolean = false,
val isCheckbox: Boolean = false,
@JdbcTypeCode(SqlTypes.JSON)
@Column(columnDefinition = "jsonb")
val readOnlyConditions: GroupCondition? = null,
val readOnlyDefaultValue: String? = null,
@JdbcTypeCode(SqlTypes.JSON)
@Column(columnDefinition = "jsonb")
val rowVisibilityCondition: RowVisibilityCondition? = null,
)

View File

@@ -1,75 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.form_element
import com.betriebsratkanzlei.legalconsenthub_api.model.TableColumnConfigDto
import com.betriebsratkanzlei.legalconsenthub_api.model.TableRowVisibilityConditionDto
import org.springframework.stereotype.Component
import com.betriebsratkanzlei.legalconsenthub_api.model.VisibilityConditionOperator as VisibilityConditionOperatorDto
@Component
class TableColumnConfigMapper(
private val filterMapper: TableColumnFilterMapper,
private val rowConstraintMapper: TableRowConstraintMapper,
private val visibilityConditionMapper: FormElementVisibilityConditionMapper,
) {
fun toTableColumnConfigDto(config: TableColumnConfig): TableColumnConfigDto =
TableColumnConfigDto(
sourceTableReference = config.sourceTableReference,
sourceColumnIndex = config.sourceColumnIndex,
filterCondition = config.filterCondition?.let { filterMapper.toTableColumnFilterDto(it) },
rowConstraint = config.rowConstraint?.let { rowConstraintMapper.toTableRowConstraintDto(it) },
isReadOnly = config.isReadOnly,
isMultipleAllowed = config.isMultipleAllowed,
isCheckbox = config.isCheckbox,
readOnlyConditions = config.readOnlyConditions?.let { visibilityConditionMapper.toGroupConditionDto(it) },
readOnlyDefaultValue = config.readOnlyDefaultValue,
rowVisibilityCondition = config.rowVisibilityCondition?.let { toRowVisibilityConditionDto(it) },
)
fun toTableColumnConfig(dto: TableColumnConfigDto): TableColumnConfig =
TableColumnConfig(
sourceTableReference = dto.sourceTableReference,
sourceColumnIndex = dto.sourceColumnIndex,
filterCondition = dto.filterCondition?.let { filterMapper.toTableColumnFilter(it) },
rowConstraint = dto.rowConstraint?.let { rowConstraintMapper.toTableRowConstraint(it) },
isReadOnly = dto.isReadOnly ?: false,
isMultipleAllowed = dto.isMultipleAllowed ?: false,
isCheckbox = dto.isCheckbox ?: false,
readOnlyConditions = dto.readOnlyConditions?.let { visibilityConditionMapper.toGroupCondition(it) },
readOnlyDefaultValue = dto.readOnlyDefaultValue,
rowVisibilityCondition = dto.rowVisibilityCondition?.let { toRowVisibilityCondition(it) },
)
private fun toRowVisibilityConditionDto(entity: RowVisibilityCondition): TableRowVisibilityConditionDto =
TableRowVisibilityConditionDto(
sourceColumnIndex = entity.sourceColumnIndex,
expectedValues = entity.expectedValues,
operator = entity.operator.toDto(),
)
private fun toRowVisibilityCondition(dto: TableRowVisibilityConditionDto): RowVisibilityCondition =
RowVisibilityCondition(
sourceColumnIndex = dto.sourceColumnIndex ?: 0,
expectedValues = dto.expectedValues ?: emptyList(),
operator = dto.operator?.toEntity() ?: VisibilityConditionOperator.CONTAINS,
)
private fun VisibilityConditionOperator.toDto(): VisibilityConditionOperatorDto =
when (this) {
VisibilityConditionOperator.EQUALS -> VisibilityConditionOperatorDto.EQUALS
VisibilityConditionOperator.NOT_EQUALS -> VisibilityConditionOperatorDto.NOT_EQUALS
VisibilityConditionOperator.IS_EMPTY -> VisibilityConditionOperatorDto.IS_EMPTY
VisibilityConditionOperator.IS_NOT_EMPTY -> VisibilityConditionOperatorDto.IS_NOT_EMPTY
VisibilityConditionOperator.CONTAINS -> VisibilityConditionOperatorDto.CONTAINS
VisibilityConditionOperator.NOT_CONTAINS -> VisibilityConditionOperatorDto.NOT_CONTAINS
}
private fun VisibilityConditionOperatorDto.toEntity(): VisibilityConditionOperator =
when (this) {
VisibilityConditionOperatorDto.EQUALS -> VisibilityConditionOperator.EQUALS
VisibilityConditionOperatorDto.NOT_EQUALS -> VisibilityConditionOperator.NOT_EQUALS
VisibilityConditionOperatorDto.IS_EMPTY -> VisibilityConditionOperator.IS_EMPTY
VisibilityConditionOperatorDto.IS_NOT_EMPTY -> VisibilityConditionOperator.IS_NOT_EMPTY
VisibilityConditionOperatorDto.CONTAINS -> VisibilityConditionOperator.CONTAINS
VisibilityConditionOperatorDto.NOT_CONTAINS -> VisibilityConditionOperator.NOT_CONTAINS
}
}

View File

@@ -1,13 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.form_element
import jakarta.persistence.Embeddable
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
@Embeddable
data class TableColumnFilter(
val sourceColumnIndex: Int? = null,
val expectedValue: String? = null,
@Enumerated(EnumType.STRING)
val operator: VisibilityConditionOperator = VisibilityConditionOperator.EQUALS,
)

View File

@@ -1,42 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.form_element
import com.betriebsratkanzlei.legalconsenthub_api.model.TableColumnFilterDto
import org.springframework.stereotype.Component
import com.betriebsratkanzlei.legalconsenthub_api.model.VisibilityConditionOperator as VisibilityConditionOperatorDto
@Component
class TableColumnFilterMapper {
fun toTableColumnFilterDto(filter: TableColumnFilter): TableColumnFilterDto =
TableColumnFilterDto(
sourceColumnIndex = filter.sourceColumnIndex,
expectedValue = filter.expectedValue,
operator = filter.operator.toDto(),
)
fun toTableColumnFilter(dto: TableColumnFilterDto): TableColumnFilter =
TableColumnFilter(
sourceColumnIndex = dto.sourceColumnIndex,
expectedValue = dto.expectedValue,
operator = dto.operator?.toEntity() ?: VisibilityConditionOperator.EQUALS,
)
private fun VisibilityConditionOperator.toDto(): VisibilityConditionOperatorDto =
when (this) {
VisibilityConditionOperator.EQUALS -> VisibilityConditionOperatorDto.EQUALS
VisibilityConditionOperator.NOT_EQUALS -> VisibilityConditionOperatorDto.NOT_EQUALS
VisibilityConditionOperator.IS_EMPTY -> VisibilityConditionOperatorDto.IS_EMPTY
VisibilityConditionOperator.IS_NOT_EMPTY -> VisibilityConditionOperatorDto.IS_NOT_EMPTY
VisibilityConditionOperator.CONTAINS -> VisibilityConditionOperatorDto.CONTAINS
VisibilityConditionOperator.NOT_CONTAINS -> VisibilityConditionOperatorDto.NOT_CONTAINS
}
private fun VisibilityConditionOperatorDto.toEntity(): VisibilityConditionOperator =
when (this) {
VisibilityConditionOperatorDto.EQUALS -> VisibilityConditionOperator.EQUALS
VisibilityConditionOperatorDto.NOT_EQUALS -> VisibilityConditionOperator.NOT_EQUALS
VisibilityConditionOperatorDto.IS_EMPTY -> VisibilityConditionOperator.IS_EMPTY
VisibilityConditionOperatorDto.IS_NOT_EMPTY -> VisibilityConditionOperator.IS_NOT_EMPTY
VisibilityConditionOperatorDto.CONTAINS -> VisibilityConditionOperator.CONTAINS
VisibilityConditionOperatorDto.NOT_CONTAINS -> VisibilityConditionOperator.NOT_CONTAINS
}
}

View File

@@ -1,9 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.form_element
import jakarta.persistence.Embeddable
@Embeddable
data class TableColumnMapping(
val sourceColumnIndex: Int,
val targetColumnIndex: Int,
)

Some files were not shown because too many files have changed in this diff Show More