diff --git a/.gitignore b/.gitignore index 93bc189..be2ee9c 100644 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,6 @@ legalconsenthub-backend/postgres-data/ ### Docker BuildKit cache ### .buildx-cache/ +### Version PDF cache (dev) ### +legalconsenthub-backend/.pdf-store/ + diff --git a/CLAUDE.md b/CLAUDE.md index 9a8cb39..ac9fc20 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` diff --git a/api/legalconsenthub.yml b/api/legalconsenthub.yml index 86e6def..4390c45 100644 --- a/api/legalconsenthub.yml +++ b/api/legalconsenthub.yml @@ -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: diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form/ApplicationFormController.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form/ApplicationFormController.kt index 4f3e0b2..4bc9c31 100644 --- a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form/ApplicationFormController.kt +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form/ApplicationFormController.kt @@ -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 { - 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')", ) diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form/ApplicationFormFormatService.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form/ApplicationFormFormatService.kt index e7d08da..02bdb71 100644 --- a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form/ApplicationFormFormatService.kt +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form/ApplicationFormFormatService.kt @@ -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 = applicationForm.formElementSections .flatMap { it.formElementSubSections } .flatMap { it.formElements } + private fun collectAllFormElements(snapshot: ApplicationFormSnapshotDto): List = + snapshot.sections + .flatMap(FormElementSectionSnapshotDto::subsections) + .flatMap(FormElementSubSectionSnapshotDto::elements) + private fun buildFormElementsByRefMap(allElements: List): Map = allElements .mapNotNull { elem -> elem.reference?.let { it to elem } } .toMap() + private fun buildSnapshotFormElementsByRefMap( + allElements: List, + ): Map = + allElements + .mapNotNull { elem -> elem.reference?.let { it to elem } } + .toMap() + private fun evaluateVisibility( allElements: List, formElementsByRef: Map, @@ -229,6 +372,24 @@ class ApplicationFormFormatService( } } + private fun isElementVisible( + element: FormElementSnapshotDto, + formElementsByRef: Map, + ): 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() + } } diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form_version/ApplicationFormVersionController.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form_version/ApplicationFormVersionController.kt index 4522622..f0c4b34 100644 --- a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form_version/ApplicationFormVersionController.kt +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form_version/ApplicationFormVersionController.kt @@ -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 { + 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')", ) diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form_version/ApplicationFormVersionService.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form_version/ApplicationFormVersionService.kt index 3f62d38..b3d43cb 100644 --- a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form_version/ApplicationFormVersionService.kt +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form_version/ApplicationFormVersionService.kt @@ -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) diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form_version/pdf/ApplicationFormVersionPdfService.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form_version/pdf/ApplicationFormVersionPdfService.kt new file mode 100644 index 0000000..e92ad8b --- /dev/null +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form_version/pdf/ApplicationFormVersionPdfService.kt @@ -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() + + 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() + } + } +} diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form_version/pdf/FileSystemPdfStorage.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form_version/pdf/FileSystemPdfStorage.kt new file mode 100644 index 0000000..b6ecb1f --- /dev/null +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form_version/pdf/FileSystemPdfStorage.kt @@ -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) } + } +} diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form_version/pdf/PdfStorage.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form_version/pdf/PdfStorage.kt new file mode 100644 index 0000000..2510120 --- /dev/null +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form_version/pdf/PdfStorage.kt @@ -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, + ) +} diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form_version/pdf/PdfStorageKey.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form_version/pdf/PdfStorageKey.kt new file mode 100644 index 0000000..4dd77d3 --- /dev/null +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form_version/pdf/PdfStorageKey.kt @@ -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 = + listOf( + applicationFormId.toString(), + "v$versionNumber.pdf", + ) +} diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form_version/pdf/PdfStorageProperties.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form_version/pdf/PdfStorageProperties.kt new file mode 100644 index 0000000..56005db --- /dev/null +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form_version/pdf/PdfStorageProperties.kt @@ -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", + ) +} diff --git a/legalconsenthub/app/components/VersionHistory.vue b/legalconsenthub/app/components/VersionHistory.vue index 8983626..23ddc20 100644 --- a/legalconsenthub/app/components/VersionHistory.vue +++ b/legalconsenthub/app/components/VersionHistory.vue @@ -24,6 +24,15 @@ {{ getStatusLabel(version.status) }} + ( + const applicationFormAsync = useAsyncData( `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(() => data?.value as ApplicationFormDto) + const applicationForm = computed(() => applicationFormAsync.data.value as ApplicationFormDto) + + const latestVersionNumber = computed(() => { + 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 } } diff --git a/legalconsenthub/i18n/locales/de.json b/legalconsenthub/i18n/locales/de.json index dfd8a33..684f7d3 100644 --- a/legalconsenthub/i18n/locales/de.json +++ b/legalconsenthub/i18n/locales/de.json @@ -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", diff --git a/legalconsenthub/i18n/locales/en.json b/legalconsenthub/i18n/locales/en.json index 5f41413..1c9b319 100644 --- a/legalconsenthub/i18n/locales/en.json +++ b/legalconsenthub/i18n/locales/en.json @@ -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",