feat(#3): Add PDF caching

This commit is contained in:
2025-12-23 10:09:30 +01:00
parent 9999ac3bb4
commit 4dfa62fcfe
16 changed files with 469 additions and 37 deletions

View File

@@ -4,10 +4,6 @@ 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.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
@@ -62,20 +58,6 @@ class ApplicationFormController(
)
}
@PreAuthorize(
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL', 'EMPLOYEE')",
)
override fun getApplicationFormPdf(id: UUID): ResponseEntity<Resource> {
val applicationForm = applicationFormService.getApplicationFormById(id)
val pdfBytes = applicationFormFormatService.generatePdf(applicationForm)
val resource = ByteArrayResource(pdfBytes)
return ResponseEntity
.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"form-$id.pdf\"")
.contentType(MediaType.APPLICATION_PDF)
.body(resource)
}
@PreAuthorize(
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL')",
)

View File

@@ -12,12 +12,19 @@ import com.betriebsratkanzlei.legalconsenthub.form_element.FormElementSection
import com.betriebsratkanzlei.legalconsenthub.form_element.FormElementSubSection
import com.betriebsratkanzlei.legalconsenthub.form_element.VisibilityConditionOperator
import com.betriebsratkanzlei.legalconsenthub.form_element.VisibilityConditionType
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 org.springframework.stereotype.Service
import org.thymeleaf.TemplateEngine
import org.thymeleaf.context.Context
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.UUID
import com.betriebsratkanzlei.legalconsenthub_api.model.VisibilityConditionOperator as VisibilityConditionOperatorDto
import com.betriebsratkanzlei.legalconsenthub_api.model.VisibilityConditionType as VisibilityConditionTypeDto
@Service
class ApplicationFormFormatService(
@@ -30,6 +37,14 @@ class ApplicationFormFormatService(
return pdfRenderer.render(latexContent)
}
fun generatePdf(
snapshot: ApplicationFormSnapshotDto,
createdAt: LocalDateTime?,
): ByteArray {
val latexContent = generateLatex(snapshot, createdAt)
return pdfRenderer.render(latexContent)
}
fun generateLatex(applicationForm: ApplicationForm): String {
val filteredForm = filterVisibleElements(applicationForm)
val exportModel = buildLatexExportModel(filteredForm)
@@ -41,6 +56,20 @@ class ApplicationFormFormatService(
return templateEngine.process("application_form_latex_template", context)
}
fun generateLatex(
snapshot: ApplicationFormSnapshotDto,
createdAt: LocalDateTime?,
): 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)
}
fun generateHtml(applicationForm: ApplicationForm): String {
val filteredForm = filterVisibleElements(applicationForm)
val context =
@@ -85,6 +114,44 @@ class ApplicationFormFormatService(
)
}
private fun buildLatexExportModel(
snapshot: ApplicationFormSnapshotDto,
createdAt: LocalDateTime?,
): 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?.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 ->
LatexFormElement(
title = LatexEscaper.escape(element.title ?: ""),
description = LatexEscaper.escape(element.description ?: ""),
value = renderElementValue(element),
)
},
)
},
)
},
)
}
private fun renderElementValue(element: FormElement): String =
when (element.type.name) {
"TEXTFIELD", "TEXTAREA" -> {
@@ -127,6 +194,48 @@ class ApplicationFormFormatService(
else -> "Keine Auswahl getroffen"
}
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"
}
else -> "Keine Auswahl getroffen"
}
private fun filterVisibleElements(applicationForm: ApplicationForm): ApplicationForm {
val allElements = collectAllFormElements(applicationForm)
val formElementsByRef = buildFormElementsByRefMap(allElements)
@@ -187,16 +296,50 @@ class ApplicationFormFormatService(
)
}
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(applicationForm: ApplicationForm): List<FormElement> =
applicationForm.formElementSections
.flatMap { it.formElementSubSections }
.flatMap { it.formElements }
private fun collectAllFormElements(snapshot: ApplicationFormSnapshotDto): List<FormElementSnapshotDto> =
snapshot.sections
.flatMap(FormElementSectionSnapshotDto::subsections)
.flatMap(FormElementSubSectionSnapshotDto::elements)
private fun buildFormElementsByRefMap(allElements: List<FormElement>): Map<String, FormElement> =
allElements
.mapNotNull { elem -> elem.reference?.let { it to elem } }
.toMap()
private fun buildSnapshotFormElementsByRefMap(
allElements: List<FormElementSnapshotDto>,
): Map<String, FormElementSnapshotDto> =
allElements
.mapNotNull { elem -> elem.reference?.let { it to elem } }
.toMap()
private fun evaluateVisibility(
allElements: List<FormElement>,
formElementsByRef: Map<String, FormElement>,
@@ -229,6 +372,24 @@ class ApplicationFormFormatService(
}
}
private fun isElementVisible(
element: FormElementSnapshotDto,
formElementsByRef: Map<String, FormElementSnapshotDto>,
): Boolean {
val condition = element.visibilityCondition ?: return true
val sourceElement = formElementsByRef[condition.sourceFormElementReference] ?: return false
val sourceValue = getFormElementValue(sourceElement)
val operator = condition.formElementOperator ?: VisibilityConditionOperatorDto.EQUALS
val conditionMet = evaluateCondition(sourceValue, condition.formElementExpectedValue, operator)
return when (condition.formElementConditionType) {
VisibilityConditionTypeDto.SHOW -> conditionMet
VisibilityConditionTypeDto.HIDE -> !conditionMet
}
}
private fun getFormElementValue(element: FormElement): String =
when (element.type.name) {
"SELECT",
@@ -240,6 +401,17 @@ class ApplicationFormFormatService(
else -> element.options.firstOrNull()?.value ?: ""
}
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,
@@ -251,4 +423,16 @@ class ApplicationFormFormatService(
VisibilityConditionOperator.IS_EMPTY -> actualValue.isEmpty()
VisibilityConditionOperator.IS_NOT_EMPTY -> actualValue.isNotEmpty()
}
private fun evaluateCondition(
actualValue: String,
expectedValue: String,
operator: VisibilityConditionOperatorDto,
): Boolean =
when (operator) {
VisibilityConditionOperatorDto.EQUALS -> actualValue.equals(expectedValue, ignoreCase = true)
VisibilityConditionOperatorDto.NOT_EQUALS -> !actualValue.equals(expectedValue, ignoreCase = true)
VisibilityConditionOperatorDto.IS_EMPTY -> actualValue.isEmpty()
VisibilityConditionOperatorDto.IS_NOT_EMPTY -> actualValue.isNotEmpty()
}
}

View File

@@ -1,11 +1,16 @@
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
@@ -17,6 +22,7 @@ class ApplicationFormVersionController(
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')",
@@ -37,6 +43,22 @@ class ApplicationFormVersionController(
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')",
)

View File

@@ -7,6 +7,7 @@ import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormVersionNotFou
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.FormOption
import com.betriebsratkanzlei.legalconsenthub.form_element.SectionSpawnTriggerMapper
import com.betriebsratkanzlei.legalconsenthub.user.User
@@ -26,6 +27,7 @@ class ApplicationFormVersionService(
private val applicationFormRepository: ApplicationFormRepository,
private val objectMapper: ObjectMapper,
private val spawnTriggerMapper: SectionSpawnTriggerMapper,
private val visibilityConditionMapper: FormElementVisibilityConditionMapper,
) {
@Transactional
fun createVersion(
@@ -130,6 +132,12 @@ class ApplicationFormVersionService(
employeeDataCategory = option.employeeDataCategory,
)
},
visibilityCondition =
element.visibilityCondition?.let {
visibilityConditionMapper.toFormElementVisibilityConditionDto(
it,
)
},
sectionSpawnTrigger =
element.sectionSpawnTrigger?.let {
spawnTriggerMapper.toSectionSpawnTriggerDto(it)
@@ -185,6 +193,10 @@ class ApplicationFormVersionService(
employeeDataCategory = optionDto.employeeDataCategory,
)
}.toMutableList(),
visibilityCondition =
elementSnapshot.visibilityCondition?.let {
visibilityConditionMapper.toFormElementVisibilityCondition(it)
},
sectionSpawnTrigger =
elementSnapshot.sectionSpawnTrigger?.let {
spawnTriggerMapper.toSectionSpawnTrigger(it)

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,33 @@
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",
)
}