feat(#3): Add PDF caching
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -67,3 +67,6 @@ legalconsenthub-backend/postgres-data/
|
||||
### Docker BuildKit cache ###
|
||||
.buildx-cache/
|
||||
|
||||
### Version PDF cache (dev) ###
|
||||
legalconsenthub-backend/.pdf-store/
|
||||
|
||||
|
||||
@@ -134,7 +134,7 @@ Forms can be exported as:
|
||||
|
||||
Endpoints:
|
||||
- `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
|
||||
|
||||
@@ -585,7 +585,7 @@ Migrations are managed manually using **Liquibase** in `src/main/resources/db/ch
|
||||
- `GET /application-forms/{id}` - Get form by ID
|
||||
- `PUT /application-forms/{id}` - Update 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
|
||||
- `POST /application-forms/{id}/submit` - Submit form
|
||||
- `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.
|
||||
- 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}/pdf`
|
||||
- `GET /application-forms/{id}/versions/{versionNumber}/pdf`
|
||||
- Verify these render correctly (with saved values):
|
||||
- `TEXTFIELD`, `TEXTAREA`, `DATE`, `RICH_TEXT`
|
||||
- `SELECT`, `RADIOBUTTON`, `CHECKBOX`, `SWITCH`
|
||||
|
||||
@@ -134,12 +134,12 @@ paths:
|
||||
"503":
|
||||
$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:
|
||||
summary: Returns the application form rendered as PDF
|
||||
operationId: getApplicationFormPdf
|
||||
summary: Returns the application form version rendered as PDF
|
||||
operationId: getApplicationFormVersionPdf
|
||||
tags:
|
||||
- application-form
|
||||
- application-form-version
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
@@ -147,9 +147,15 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
- name: versionNumber
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
format: int32
|
||||
responses:
|
||||
'200':
|
||||
description: Application form as PDF
|
||||
description: Application form version as PDF
|
||||
content:
|
||||
application/pdf:
|
||||
schema:
|
||||
|
||||
@@ -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')",
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')",
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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",
|
||||
)
|
||||
}
|
||||
@@ -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",
|
||||
)
|
||||
}
|
||||
@@ -24,6 +24,15 @@
|
||||
<UBadge :color="getStatusColor(version.status)">
|
||||
{{ getStatusLabel(version.status) }}
|
||||
</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
|
||||
icon="i-lucide-git-compare"
|
||||
size="sm"
|
||||
|
||||
@@ -1,30 +1,46 @@
|
||||
import type { ApplicationFormDto } from '~~/.api-client'
|
||||
|
||||
export async function useApplicationFormNavigation(applicationFormId: string) {
|
||||
const { t } = useI18n()
|
||||
const { getApplicationFormById } = useApplicationForm()
|
||||
const { getVersions } = useApplicationFormVersion()
|
||||
|
||||
const { data, error, refresh } = await useAsyncData<ApplicationFormDto>(
|
||||
const applicationFormAsync = useAsyncData<ApplicationFormDto>(
|
||||
`application-form-${applicationFormId}`,
|
||||
async () => await getApplicationFormById(applicationFormId),
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
if (error.value) {
|
||||
throw createError({ statusText: error.value.message })
|
||||
const versionsAsync = useAsyncData(
|
||||
`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(() => [
|
||||
[
|
||||
{
|
||||
label: 'Formular',
|
||||
label: t('applicationForms.tabs.form'),
|
||||
icon: 'i-lucide-file',
|
||||
to: `/application-forms/${applicationForm.value.id}/0`,
|
||||
exact: true
|
||||
},
|
||||
{
|
||||
label: 'Versionen',
|
||||
label: t('applicationForms.tabs.versions'),
|
||||
icon: 'i-lucide-file-clock',
|
||||
to: `/application-forms/${applicationForm.value.id}/versions`,
|
||||
exact: true
|
||||
@@ -32,16 +48,23 @@ export async function useApplicationFormNavigation(applicationFormId: string) {
|
||||
],
|
||||
[
|
||||
{
|
||||
label: 'Vorschau',
|
||||
label: t('applicationForms.tabs.preview'),
|
||||
icon: 'i-lucide-file-text',
|
||||
to: `/api/application-forms/${applicationForm.value.id}/pdf`,
|
||||
target: '_blank'
|
||||
to: `/api/application-forms/${applicationForm.value.id}/versions/${latestVersionNumber.value}/pdf`,
|
||||
target: '_blank',
|
||||
disabled: Boolean(versionsAsync.error.value) || latestVersionNumber.value === null
|
||||
}
|
||||
]
|
||||
])
|
||||
|
||||
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 {
|
||||
@@ -49,6 +72,6 @@ export async function useApplicationFormNavigation(applicationFormId: string) {
|
||||
navigationLinks,
|
||||
refresh,
|
||||
updateApplicationForm,
|
||||
error
|
||||
error: applicationFormAsync.error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,11 @@
|
||||
"next": "Weiter",
|
||||
"save": "Speichern",
|
||||
"submit": "Einreichen"
|
||||
},
|
||||
"tabs": {
|
||||
"form": "Formular",
|
||||
"versions": "Versionen",
|
||||
"preview": "Vorschau"
|
||||
}
|
||||
},
|
||||
"templates": {
|
||||
@@ -64,6 +69,7 @@
|
||||
"unknownError": "Unbekannter Fehler",
|
||||
"compare": "Vergleichen",
|
||||
"restore": "Wiederherstellen",
|
||||
"openPdf": "PDF öffnen",
|
||||
"restored": "Erfolg",
|
||||
"restoredDescription": "Das Formular wurde auf die ausgewählte Version zurückgesetzt.",
|
||||
"restoreError": "Version konnte nicht wiederhergestellt werden",
|
||||
|
||||
@@ -37,6 +37,11 @@
|
||||
"next": "Next",
|
||||
"save": "Save",
|
||||
"submit": "Submit"
|
||||
},
|
||||
"tabs": {
|
||||
"form": "Form",
|
||||
"versions": "Versions",
|
||||
"preview": "Preview"
|
||||
}
|
||||
},
|
||||
"templates": {
|
||||
@@ -64,6 +69,7 @@
|
||||
"unknownError": "Unknown error",
|
||||
"compare": "Compare",
|
||||
"restore": "Restore",
|
||||
"openPdf": "Open PDF",
|
||||
"restored": "Success",
|
||||
"restoredDescription": "The form has been restored to the selected version.",
|
||||
"restoreError": "Version could not be restored",
|
||||
|
||||
Reference in New Issue
Block a user