major: Rename legalconsenthub to gremiumhub
This commit is contained in:
@@ -1,15 +0,0 @@
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
*.md
|
||||
.gradle
|
||||
build
|
||||
bin
|
||||
!gradle/wrapper
|
||||
postgres-data
|
||||
docker-compose.yaml
|
||||
.idea
|
||||
.vscode
|
||||
*.log
|
||||
*.iml
|
||||
|
||||
@@ -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
|
||||
|
||||
3
legalconsenthub-backend/.gitattributes
vendored
3
legalconsenthub-backend/.gitattributes
vendored
@@ -1,3 +0,0 @@
|
||||
/gradlew text eol=lf
|
||||
*.bat text eol=crlf
|
||||
*.jar binary
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
# Legal Consent Hub Backend
|
||||
|
||||
## Pipeline Triggering
|
||||
|
||||
Trigger count: 14
|
||||
@@ -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)
|
||||
Binary file not shown.
@@ -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
|
||||
252
legalconsenthub-backend/gradlew
vendored
252
legalconsenthub-backend/gradlew
vendored
@@ -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" "$@"
|
||||
94
legalconsenthub-backend/gradlew.bat
vendored
94
legalconsenthub-backend/gradlew.bat
vendored
@@ -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
|
||||
@@ -1 +0,0 @@
|
||||
rootProject.name = 'legalconsenthub'
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace("'", "'")
|
||||
.replace(" ", " ")
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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!"),
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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",
|
||||
)
|
||||
}
|
||||
@@ -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",
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
)
|
||||
@@ -1,5 +0,0 @@
|
||||
package com.betriebsratkanzlei.legalconsenthub.error
|
||||
|
||||
class ApplicationFormNotCreatedException(
|
||||
e: Exception,
|
||||
) : RuntimeException("Couldn't create application form", e)
|
||||
@@ -1,5 +0,0 @@
|
||||
package com.betriebsratkanzlei.legalconsenthub.error
|
||||
|
||||
class ApplicationFormNotDeletedException(
|
||||
e: Exception,
|
||||
) : RuntimeException("Couldn't delete application form", e)
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -1,5 +0,0 @@
|
||||
package com.betriebsratkanzlei.legalconsenthub.error
|
||||
|
||||
class CommentNotCreatedException(
|
||||
e: Exception,
|
||||
) : RuntimeException("Couldn't create comment", e)
|
||||
@@ -1,5 +0,0 @@
|
||||
package com.betriebsratkanzlei.legalconsenthub.error
|
||||
|
||||
class CommentNotDeletedException(
|
||||
e: Exception,
|
||||
) : RuntimeException("Couldn't delete comment", e)
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -1,6 +0,0 @@
|
||||
package com.betriebsratkanzlei.legalconsenthub.error
|
||||
|
||||
class FileStorageException(
|
||||
message: String,
|
||||
cause: Throwable? = null,
|
||||
) : RuntimeException(message, cause)
|
||||
@@ -1,5 +0,0 @@
|
||||
package com.betriebsratkanzlei.legalconsenthub.error
|
||||
|
||||
class FileTooLargeException(
|
||||
message: String,
|
||||
) : RuntimeException(message)
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -1,5 +0,0 @@
|
||||
package com.betriebsratkanzlei.legalconsenthub.error
|
||||
|
||||
class UnsupportedFileTypeException(
|
||||
message: String,
|
||||
) : RuntimeException(message)
|
||||
@@ -1,5 +0,0 @@
|
||||
package com.betriebsratkanzlei.legalconsenthub.error
|
||||
|
||||
class UserAlreadyExistsException(
|
||||
id: String,
|
||||
) : RuntimeException("User with ID $id already exists")
|
||||
@@ -1,5 +0,0 @@
|
||||
package com.betriebsratkanzlei.legalconsenthub.error
|
||||
|
||||
class UserNotFoundException(
|
||||
id: String,
|
||||
) : RuntimeException("Couldn't find user with ID: $id")
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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) },
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
)
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user