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

3
.gitignore vendored
View File

@@ -67,3 +67,6 @@ legalconsenthub-backend/postgres-data/
### Docker BuildKit cache ### ### Docker BuildKit cache ###
.buildx-cache/ .buildx-cache/
### Version PDF cache (dev) ###
legalconsenthub-backend/.pdf-store/

View File

@@ -134,7 +134,7 @@ Forms can be exported as:
Endpoints: Endpoints:
- `GET /application-forms/{id}/html` - Returns HTML representation - `GET /application-forms/{id}/html` - Returns HTML representation
- `GET /application-forms/{id}/pdf` - Returns PDF (inline display) - `GET /application-forms/{id}/versions/{versionNumber}/pdf` - Returns version PDF (inline display)
### 6. Comments System ### 6. Comments System
@@ -585,7 +585,7 @@ Migrations are managed manually using **Liquibase** in `src/main/resources/db/ch
- `GET /application-forms/{id}` - Get form by ID - `GET /application-forms/{id}` - Get form by ID
- `PUT /application-forms/{id}` - Update form - `PUT /application-forms/{id}` - Update form
- `DELETE /application-forms/{id}` - Delete form - `DELETE /application-forms/{id}` - Delete form
- `GET /application-forms/{id}/pdf` - Export as PDF - `GET /application-forms/{id}/versions/{versionNumber}/pdf` - Export a version as PDF
- `GET /application-forms/{id}/html` - Export as HTML - `GET /application-forms/{id}/html` - Export as HTML
- `POST /application-forms/{id}/submit` - Submit form - `POST /application-forms/{id}/submit` - Submit form
- `POST /application-forms/{applicationFormId}/subsections/{subsectionId}/form-elements?position={n}` - Add form element dynamically - `POST /application-forms/{applicationFormId}/subsections/{subsectionId}/form-elements?position={n}` - Add form element dynamically
@@ -741,7 +741,7 @@ act -n
- Any change that touches form element types, options/value storage, visibility conditions, or section spawning can break PDF output. - Any change that touches form element types, options/value storage, visibility conditions, or section spawning can break PDF output.
- Always validate **both** HTML and PDF export for a real form instance (including newly spawned sections and newly added text fields with values): - Always validate **both** HTML and PDF export for a real form instance (including newly spawned sections and newly added text fields with values):
- `GET /application-forms/{id}/html` - `GET /application-forms/{id}/html`
- `GET /application-forms/{id}/pdf` - `GET /application-forms/{id}/versions/{versionNumber}/pdf`
- Verify these render correctly (with saved values): - Verify these render correctly (with saved values):
- `TEXTFIELD`, `TEXTAREA`, `DATE`, `RICH_TEXT` - `TEXTFIELD`, `TEXTAREA`, `DATE`, `RICH_TEXT`
- `SELECT`, `RADIOBUTTON`, `CHECKBOX`, `SWITCH` - `SELECT`, `RADIOBUTTON`, `CHECKBOX`, `SWITCH`

View File

@@ -134,12 +134,12 @@ paths:
"503": "503":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServiceUnavailable" $ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServiceUnavailable"
/application-forms/{id}/pdf: /application-forms/{id}/versions/{versionNumber}/pdf:
get: get:
summary: Returns the application form rendered as PDF summary: Returns the application form version rendered as PDF
operationId: getApplicationFormPdf operationId: getApplicationFormVersionPdf
tags: tags:
- application-form - application-form-version
parameters: parameters:
- name: id - name: id
in: path in: path
@@ -147,9 +147,15 @@ paths:
schema: schema:
type: string type: string
format: uuid format: uuid
- name: versionNumber
in: path
required: true
schema:
type: integer
format: int32
responses: responses:
'200': '200':
description: Application form as PDF description: Application form version as PDF
content: content:
application/pdf: application/pdf:
schema: schema:

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.ApplicationFormDto
import com.betriebsratkanzlei.legalconsenthub_api.model.FormElementDto import com.betriebsratkanzlei.legalconsenthub_api.model.FormElementDto
import com.betriebsratkanzlei.legalconsenthub_api.model.PagedApplicationFormDto 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.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.RestController 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( @PreAuthorize(
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL')", "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.FormElementSubSection
import com.betriebsratkanzlei.legalconsenthub.form_element.VisibilityConditionOperator import com.betriebsratkanzlei.legalconsenthub.form_element.VisibilityConditionOperator
import com.betriebsratkanzlei.legalconsenthub.form_element.VisibilityConditionType 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.springframework.stereotype.Service
import org.thymeleaf.TemplateEngine import org.thymeleaf.TemplateEngine
import org.thymeleaf.context.Context import org.thymeleaf.context.Context
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.util.UUID import java.util.UUID
import com.betriebsratkanzlei.legalconsenthub_api.model.VisibilityConditionOperator as VisibilityConditionOperatorDto
import com.betriebsratkanzlei.legalconsenthub_api.model.VisibilityConditionType as VisibilityConditionTypeDto
@Service @Service
class ApplicationFormFormatService( class ApplicationFormFormatService(
@@ -30,6 +37,14 @@ class ApplicationFormFormatService(
return pdfRenderer.render(latexContent) 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 { fun generateLatex(applicationForm: ApplicationForm): String {
val filteredForm = filterVisibleElements(applicationForm) val filteredForm = filterVisibleElements(applicationForm)
val exportModel = buildLatexExportModel(filteredForm) val exportModel = buildLatexExportModel(filteredForm)
@@ -41,6 +56,20 @@ class ApplicationFormFormatService(
return templateEngine.process("application_form_latex_template", context) 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 { fun generateHtml(applicationForm: ApplicationForm): String {
val filteredForm = filterVisibleElements(applicationForm) val filteredForm = filterVisibleElements(applicationForm)
val context = 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 = private fun renderElementValue(element: FormElement): String =
when (element.type.name) { when (element.type.name) {
"TEXTFIELD", "TEXTAREA" -> { "TEXTFIELD", "TEXTAREA" -> {
@@ -127,6 +194,48 @@ class ApplicationFormFormatService(
else -> "Keine Auswahl getroffen" 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 { private fun filterVisibleElements(applicationForm: ApplicationForm): ApplicationForm {
val allElements = collectAllFormElements(applicationForm) val allElements = collectAllFormElements(applicationForm)
val formElementsByRef = buildFormElementsByRefMap(allElements) 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> = private fun collectAllFormElements(applicationForm: ApplicationForm): List<FormElement> =
applicationForm.formElementSections applicationForm.formElementSections
.flatMap { it.formElementSubSections } .flatMap { it.formElementSubSections }
.flatMap { it.formElements } .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> = private fun buildFormElementsByRefMap(allElements: List<FormElement>): Map<String, FormElement> =
allElements allElements
.mapNotNull { elem -> elem.reference?.let { it to elem } } .mapNotNull { elem -> elem.reference?.let { it to elem } }
.toMap() .toMap()
private fun buildSnapshotFormElementsByRefMap(
allElements: List<FormElementSnapshotDto>,
): Map<String, FormElementSnapshotDto> =
allElements
.mapNotNull { elem -> elem.reference?.let { it to elem } }
.toMap()
private fun evaluateVisibility( private fun evaluateVisibility(
allElements: List<FormElement>, allElements: List<FormElement>,
formElementsByRef: Map<String, 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 = private fun getFormElementValue(element: FormElement): String =
when (element.type.name) { when (element.type.name) {
"SELECT", "SELECT",
@@ -240,6 +401,17 @@ class ApplicationFormFormatService(
else -> element.options.firstOrNull()?.value ?: "" 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( private fun evaluateCondition(
actualValue: String, actualValue: String,
expectedValue: String, expectedValue: String,
@@ -251,4 +423,16 @@ class ApplicationFormFormatService(
VisibilityConditionOperator.IS_EMPTY -> actualValue.isEmpty() VisibilityConditionOperator.IS_EMPTY -> actualValue.isEmpty()
VisibilityConditionOperator.IS_NOT_EMPTY -> actualValue.isNotEmpty() 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 package com.betriebsratkanzlei.legalconsenthub.application_form_version
import com.betriebsratkanzlei.legalconsenthub.application_form.ApplicationFormMapper 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.user.UserService
import com.betriebsratkanzlei.legalconsenthub_api.api.ApplicationFormVersionApi import com.betriebsratkanzlei.legalconsenthub_api.api.ApplicationFormVersionApi
import com.betriebsratkanzlei.legalconsenthub_api.model.ApplicationFormDto import com.betriebsratkanzlei.legalconsenthub_api.model.ApplicationFormDto
import com.betriebsratkanzlei.legalconsenthub_api.model.ApplicationFormVersionDto import com.betriebsratkanzlei.legalconsenthub_api.model.ApplicationFormVersionDto
import com.betriebsratkanzlei.legalconsenthub_api.model.ApplicationFormVersionListItemDto 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.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
@@ -17,6 +22,7 @@ class ApplicationFormVersionController(
private val versionMapper: ApplicationFormVersionMapper, private val versionMapper: ApplicationFormVersionMapper,
private val applicationFormMapper: ApplicationFormMapper, private val applicationFormMapper: ApplicationFormMapper,
private val userService: UserService, private val userService: UserService,
private val versionPdfService: ApplicationFormVersionPdfService,
) : ApplicationFormVersionApi { ) : ApplicationFormVersionApi {
@PreAuthorize( @PreAuthorize(
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL', 'EMPLOYEE')", "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)) 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( @PreAuthorize(
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL')", "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.FormElement
import com.betriebsratkanzlei.legalconsenthub.form_element.FormElementSection import com.betriebsratkanzlei.legalconsenthub.form_element.FormElementSection
import com.betriebsratkanzlei.legalconsenthub.form_element.FormElementSubSection 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.FormOption
import com.betriebsratkanzlei.legalconsenthub.form_element.SectionSpawnTriggerMapper import com.betriebsratkanzlei.legalconsenthub.form_element.SectionSpawnTriggerMapper
import com.betriebsratkanzlei.legalconsenthub.user.User import com.betriebsratkanzlei.legalconsenthub.user.User
@@ -26,6 +27,7 @@ class ApplicationFormVersionService(
private val applicationFormRepository: ApplicationFormRepository, private val applicationFormRepository: ApplicationFormRepository,
private val objectMapper: ObjectMapper, private val objectMapper: ObjectMapper,
private val spawnTriggerMapper: SectionSpawnTriggerMapper, private val spawnTriggerMapper: SectionSpawnTriggerMapper,
private val visibilityConditionMapper: FormElementVisibilityConditionMapper,
) { ) {
@Transactional @Transactional
fun createVersion( fun createVersion(
@@ -130,6 +132,12 @@ class ApplicationFormVersionService(
employeeDataCategory = option.employeeDataCategory, employeeDataCategory = option.employeeDataCategory,
) )
}, },
visibilityCondition =
element.visibilityCondition?.let {
visibilityConditionMapper.toFormElementVisibilityConditionDto(
it,
)
},
sectionSpawnTrigger = sectionSpawnTrigger =
element.sectionSpawnTrigger?.let { element.sectionSpawnTrigger?.let {
spawnTriggerMapper.toSectionSpawnTriggerDto(it) spawnTriggerMapper.toSectionSpawnTriggerDto(it)
@@ -185,6 +193,10 @@ class ApplicationFormVersionService(
employeeDataCategory = optionDto.employeeDataCategory, employeeDataCategory = optionDto.employeeDataCategory,
) )
}.toMutableList(), }.toMutableList(),
visibilityCondition =
elementSnapshot.visibilityCondition?.let {
visibilityConditionMapper.toFormElementVisibilityCondition(it)
},
sectionSpawnTrigger = sectionSpawnTrigger =
elementSnapshot.sectionSpawnTrigger?.let { elementSnapshot.sectionSpawnTrigger?.let {
spawnTriggerMapper.toSectionSpawnTrigger(it) 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",
)
}

View File

@@ -24,6 +24,15 @@
<UBadge :color="getStatusColor(version.status)"> <UBadge :color="getStatusColor(version.status)">
{{ getStatusLabel(version.status) }} {{ getStatusLabel(version.status) }}
</UBadge> </UBadge>
<UButton
icon="i-lucide-file-text"
size="sm"
color="neutral"
variant="outline"
:label="$t('versions.openPdf')"
:to="`/api/application-forms/${applicationFormId}/versions/${version.versionNumber}/pdf`"
target="_blank"
/>
<UButton <UButton
icon="i-lucide-git-compare" icon="i-lucide-git-compare"
size="sm" size="sm"

View File

@@ -1,30 +1,46 @@
import type { ApplicationFormDto } from '~~/.api-client' import type { ApplicationFormDto } from '~~/.api-client'
export async function useApplicationFormNavigation(applicationFormId: string) { export async function useApplicationFormNavigation(applicationFormId: string) {
const { t } = useI18n()
const { getApplicationFormById } = useApplicationForm() const { getApplicationFormById } = useApplicationForm()
const { getVersions } = useApplicationFormVersion()
const { data, error, refresh } = await useAsyncData<ApplicationFormDto>( const applicationFormAsync = useAsyncData<ApplicationFormDto>(
`application-form-${applicationFormId}`, `application-form-${applicationFormId}`,
async () => await getApplicationFormById(applicationFormId), async () => await getApplicationFormById(applicationFormId),
{ deep: true } { deep: true }
) )
if (error.value) { const versionsAsync = useAsyncData(
throw createError({ statusText: error.value.message }) `application-form-${applicationFormId}-versions-nav`,
async () => await getVersions(applicationFormId),
{ deep: false }
)
await Promise.all([applicationFormAsync, versionsAsync])
if (applicationFormAsync.error.value) {
throw createError({ statusText: applicationFormAsync.error.value.message })
} }
const applicationForm = computed<ApplicationFormDto>(() => data?.value as ApplicationFormDto) const applicationForm = computed<ApplicationFormDto>(() => applicationFormAsync.data.value as ApplicationFormDto)
const latestVersionNumber = computed<number | null>(() => {
const versions = versionsAsync.data.value ?? []
if (versions.length === 0) return null
return Math.max(...versions.map((v) => v.versionNumber ?? 0))
})
const navigationLinks = computed(() => [ const navigationLinks = computed(() => [
[ [
{ {
label: 'Formular', label: t('applicationForms.tabs.form'),
icon: 'i-lucide-file', icon: 'i-lucide-file',
to: `/application-forms/${applicationForm.value.id}/0`, to: `/application-forms/${applicationForm.value.id}/0`,
exact: true exact: true
}, },
{ {
label: 'Versionen', label: t('applicationForms.tabs.versions'),
icon: 'i-lucide-file-clock', icon: 'i-lucide-file-clock',
to: `/application-forms/${applicationForm.value.id}/versions`, to: `/application-forms/${applicationForm.value.id}/versions`,
exact: true exact: true
@@ -32,16 +48,23 @@ export async function useApplicationFormNavigation(applicationFormId: string) {
], ],
[ [
{ {
label: 'Vorschau', label: t('applicationForms.tabs.preview'),
icon: 'i-lucide-file-text', icon: 'i-lucide-file-text',
to: `/api/application-forms/${applicationForm.value.id}/pdf`, to: `/api/application-forms/${applicationForm.value.id}/versions/${latestVersionNumber.value}/pdf`,
target: '_blank' target: '_blank',
disabled: Boolean(versionsAsync.error.value) || latestVersionNumber.value === null
} }
] ]
]) ])
function updateApplicationForm(updatedForm: ApplicationFormDto) { function updateApplicationForm(updatedForm: ApplicationFormDto) {
data.value = updatedForm applicationFormAsync.data.value = updatedForm
// Refresh the versions list so the Preview link points to the latest version.
void versionsAsync.refresh()
}
async function refresh() {
await Promise.all([applicationFormAsync.refresh(), versionsAsync.refresh()])
} }
return { return {
@@ -49,6 +72,6 @@ export async function useApplicationFormNavigation(applicationFormId: string) {
navigationLinks, navigationLinks,
refresh, refresh,
updateApplicationForm, updateApplicationForm,
error error: applicationFormAsync.error
} }
} }

View File

@@ -37,6 +37,11 @@
"next": "Weiter", "next": "Weiter",
"save": "Speichern", "save": "Speichern",
"submit": "Einreichen" "submit": "Einreichen"
},
"tabs": {
"form": "Formular",
"versions": "Versionen",
"preview": "Vorschau"
} }
}, },
"templates": { "templates": {
@@ -64,6 +69,7 @@
"unknownError": "Unbekannter Fehler", "unknownError": "Unbekannter Fehler",
"compare": "Vergleichen", "compare": "Vergleichen",
"restore": "Wiederherstellen", "restore": "Wiederherstellen",
"openPdf": "PDF öffnen",
"restored": "Erfolg", "restored": "Erfolg",
"restoredDescription": "Das Formular wurde auf die ausgewählte Version zurückgesetzt.", "restoredDescription": "Das Formular wurde auf die ausgewählte Version zurückgesetzt.",
"restoreError": "Version konnte nicht wiederhergestellt werden", "restoreError": "Version konnte nicht wiederhergestellt werden",

View File

@@ -37,6 +37,11 @@
"next": "Next", "next": "Next",
"save": "Save", "save": "Save",
"submit": "Submit" "submit": "Submit"
},
"tabs": {
"form": "Form",
"versions": "Versions",
"preview": "Preview"
} }
}, },
"templates": { "templates": {
@@ -64,6 +69,7 @@
"unknownError": "Unknown error", "unknownError": "Unknown error",
"compare": "Compare", "compare": "Compare",
"restore": "Restore", "restore": "Restore",
"openPdf": "Open PDF",
"restored": "Success", "restored": "Success",
"restoredDescription": "The form has been restored to the selected version.", "restoredDescription": "The form has been restored to the selected version.",
"restoreError": "Version could not be restored", "restoreError": "Version could not be restored",