feat(#3): Add PDF caching
This commit is contained in:
@@ -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",
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user