feat(fullstack): Add file upload

This commit is contained in:
2026-01-25 17:57:04 +01:00
parent c0f3adac07
commit 954c6d00e1
28 changed files with 1606 additions and 42 deletions

View File

@@ -1043,18 +1043,8 @@ paths:
####### Files ####### ####### Files #######
/files: /files:
get:
summary: Get all files
operationId: getAllFiles
tags:
- file
responses:
"200":
description: Successful response
"500":
description: Internal server error
post: post:
summary: Upload a new file summary: Upload a new file for a form element
operationId: uploadFile operationId: uploadFile
tags: tags:
- file - file
@@ -1063,18 +1053,24 @@ paths:
content: content:
multipart/form-data: multipart/form-data:
schema: schema:
$ref: "#/components/schemas/UploadFileDto" $ref: "#/components/schemas/UploadFileRequestDto"
responses: responses:
"201": "201":
description: File uploaded description: File uploaded successfully
content:
application/json:
schema:
$ref: "#/components/schemas/UploadedFileDto"
"400": "400":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/BadRequest" $ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/BadRequest"
"401": "401":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/Unauthorized" $ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/Unauthorized"
"413":
description: File too large (exceeds 10MB limit)
"415":
description: Unsupported file type
"500": "500":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServerError" $ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServerError"
"503":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServiceUnavailable"
/files/{id}: /files/{id}:
parameters: parameters:
@@ -1085,25 +1081,23 @@ paths:
type: string type: string
format: uuid format: uuid
get: get:
summary: Get a specific file summary: Get file metadata by ID
operationId: getFileById operationId: getFileById
tags: tags:
- file - file
responses: responses:
"200": "200":
description: Get file by ID description: File metadata
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/FileDto" $ref: "#/components/schemas/UploadedFileDto"
"400": "404":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/BadRequest" description: File not found
"401": "401":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/Unauthorized" $ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/Unauthorized"
"500": "500":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServerError" $ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServerError"
"503":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServiceUnavailable"
delete: delete:
summary: Delete a file summary: Delete a file
operationId: deleteFile operationId: deleteFile
@@ -1116,10 +1110,123 @@ paths:
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/BadRequest" $ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/BadRequest"
"401": "401":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/Unauthorized" $ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/Unauthorized"
"404":
description: File not found
"500":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServerError"
/files/{id}/content:
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
get:
summary: Download file content
operationId: downloadFileContent
tags:
- file
parameters:
- name: inline
in: query
required: false
schema:
type: boolean
default: false
description: If true, return file with inline disposition for browser viewing
responses:
"200":
description: File binary content
content:
application/octet-stream:
schema:
type: string
format: binary
headers:
Content-Disposition:
description: Attachment filename
schema:
type: string
Content-Type:
description: File MIME type
schema:
type: string
"404":
description: File not found
"401":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/Unauthorized"
"500":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServerError"
/application-forms/{applicationFormId}/files:
parameters:
- name: applicationFormId
in: path
required: true
schema:
type: string
format: uuid
description: The application form ID
get:
summary: Get all files for an application form
operationId: getFilesByApplicationForm
tags:
- file
parameters:
- name: formElementReference
in: query
required: false
schema:
type: string
description: Filter by form element reference key
responses:
"200":
description: List of files for the application form
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/UploadedFileDto"
"401":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/Unauthorized"
"404":
description: Application form not found
"500":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServerError"
post:
summary: Associate files with an application form
operationId: associateFilesWithApplicationForm
tags:
- file
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- fileIds
properties:
fileIds:
type: array
items:
type: string
format: uuid
description: List of file IDs to associate with this application form
responses:
"204":
description: Files successfully associated
"400":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/BadRequest"
"401":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/Unauthorized"
"404":
description: Application form or file not found
"500": "500":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServerError" $ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServerError"
"503":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServiceUnavailable"
components: components:
securitySchemes: securitySchemes:
@@ -1177,6 +1284,14 @@ components:
nullable: true nullable: true
readOnly: true readOnly: true
description: Total number of comments associated with this application form. description: Total number of comments associated with this application form.
fileIds:
type: array
items:
type: string
format: uuid
nullable: true
writeOnly: true
description: Temporary file IDs to associate atomically on creation (write-only, ignored on read)
PagedApplicationFormDto: PagedApplicationFormDto:
type: object type: object
@@ -1574,6 +1689,7 @@ components:
- RICH_TEXT - RICH_TEXT
- DATE - DATE
- TABLE - TABLE
- FILE_UPLOAD
FormElementVisibilityCondition: FormElementVisibilityCondition:
type: object type: object
@@ -1880,41 +1996,79 @@ components:
- WARNING - WARNING
- ERROR - ERROR
####### FileDto ####### ####### File Upload DTOs #######
FileDto: UploadedFileDto:
type: object type: object
required: required:
- id - id
- name - filename
- file - originalFilename
- createdAt - size
- modifiedAt - mimeType
- formElementReference
- uploadedAt
properties: properties:
id: id:
type: string type: string
format: uuid format: uuid
name: description: Unique identifier for the uploaded file
filename:
type: string type: string
file: description: Unique filename stored on disk (UUID-based)
originalFilename:
type: string type: string
createdAt: description: Original filename provided by the user
type: string size:
format: date-time type: integer
modifiedAt: format: int64
description: File size in bytes
mimeType:
type: string
description: MIME type (e.g., application/pdf, image/jpeg)
organizationId:
type: string
nullable: true
description: Organization context (null for global forms)
applicationFormId:
type: string
format: uuid
nullable: true
description: The application form this file belongs to (null for temporary uploads)
formElementReference:
type: string
description: Reference key of the form element (e.g., grundrechte_folgenabschaetzung)
uploadedAt:
type: string type: string
format: date-time format: date-time
description: Timestamp when the file was uploaded
uploadedBy:
nullable: true
allOf:
- $ref: "#/components/schemas/UserDto"
description: User who uploaded the file
UploadFileDto: UploadFileRequestDto:
type: object type: object
required: required:
- name
- file - file
- formElementReference
properties: properties:
name:
type: string
file: file:
type: string type: string
format: binary format: binary
description: The file to upload
organizationId:
type: string
nullable: true
description: Organization context (null for global forms)
applicationFormId:
type: string
format: uuid
nullable: true
description: The application form this file belongs to (null for temporary uploads before form is saved)
formElementReference:
type: string
description: Reference key of the form element
####### Miscellaneous ####### ####### Miscellaneous #######
ProcessingPurpose: ProcessingPurpose:

View File

@@ -36,6 +36,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-actuator' implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'org.springframework.boot:spring-boot-starter-mail' implementation 'org.springframework.boot:spring-boot-starter-mail'
implementation 'org.apache.tika:tika-core:2.9.1'
runtimeOnly 'com.h2database:h2' runtimeOnly 'com.h2database:h2'
implementation 'org.postgresql:postgresql' implementation 'org.postgresql:postgresql'
implementation 'org.springframework.boot:spring-boot-testcontainers' implementation 'org.springframework.boot:spring-boot-testcontainers'

View File

@@ -133,6 +133,27 @@ class ApplicationFormFormatService(
"SWITCH" -> { "SWITCH" -> {
if (element.options.any { it.value == "true" }) "Ja" else "Nein" if (element.options.any { it.value == "true" }) "Ja" else "Nein"
} }
"FILE_UPLOAD" -> {
val files =
element.options.mapNotNull { option ->
val value = option.value
if (value.isBlank()) {
null
} else {
try {
val metadata = jacksonObjectMapper().readValue(value, Map::class.java)
metadata["filename"] as? String
} catch (_: Exception) {
null
}
}
}
if (files.isEmpty()) {
"Keine Dateien hochgeladen"
} else {
files.joinToString("\\\\") { LatexEscaper.escape(it) }
}
}
"TABLE" -> { "TABLE" -> {
renderTableValue(element).first renderTableValue(element).first
} }

View File

@@ -10,6 +10,7 @@ import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotCreatedExc
import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotDeletedException import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotDeletedException
import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotFoundException import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotFoundException
import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotUpdatedException import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotUpdatedException
import com.betriebsratkanzlei.legalconsenthub.file.FileService
import com.betriebsratkanzlei.legalconsenthub.form_element.FormElementMapper import com.betriebsratkanzlei.legalconsenthub.form_element.FormElementMapper
import com.betriebsratkanzlei.legalconsenthub.notification.NotificationService import com.betriebsratkanzlei.legalconsenthub.notification.NotificationService
import com.betriebsratkanzlei.legalconsenthub.user.UserService import com.betriebsratkanzlei.legalconsenthub.user.UserService
@@ -22,6 +23,7 @@ import org.springframework.context.ApplicationEventPublisher
import org.springframework.data.domain.Page import org.springframework.data.domain.Page
import org.springframework.data.domain.PageRequest import org.springframework.data.domain.PageRequest
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.util.UUID import java.util.UUID
data class ApplicationFormPageWithCommentCounts( data class ApplicationFormPageWithCommentCounts(
@@ -39,7 +41,9 @@ class ApplicationFormService(
private val userService: UserService, private val userService: UserService,
private val eventPublisher: ApplicationEventPublisher, private val eventPublisher: ApplicationEventPublisher,
private val commentRepository: CommentRepository, private val commentRepository: CommentRepository,
private val fileService: FileService,
) { ) {
@Transactional(rollbackFor = [Exception::class])
fun createApplicationForm(applicationFormDto: ApplicationFormDto): ApplicationForm { fun createApplicationForm(applicationFormDto: ApplicationFormDto): ApplicationForm {
val applicationForm = applicationFormMapper.toNewApplicationForm(applicationFormDto) val applicationForm = applicationFormMapper.toNewApplicationForm(applicationFormDto)
val savedApplicationForm: ApplicationForm val savedApplicationForm: ApplicationForm
@@ -49,6 +53,15 @@ class ApplicationFormService(
throw ApplicationFormNotCreatedException(e) throw ApplicationFormNotCreatedException(e)
} }
// Associate files atomically if provided
val fileIds = applicationFormDto.fileIds
if (!fileIds.isNullOrEmpty()) {
fileService.associateTemporaryFilesTransactional(
fileIds,
savedApplicationForm,
)
}
val currentUser = userService.getCurrentUser() val currentUser = userService.getCurrentUser()
versionService.createVersion(savedApplicationForm, currentUser) versionService.createVersion(savedApplicationForm, currentUser)

View File

@@ -0,0 +1,8 @@
package com.betriebsratkanzlei.legalconsenthub.error
import java.util.UUID
class FileNotFoundException(
id: UUID,
message: String = "File not found: $id",
) : RuntimeException(message)

View File

@@ -0,0 +1,6 @@
package com.betriebsratkanzlei.legalconsenthub.error
class FileStorageException(
message: String,
cause: Throwable? = null,
) : RuntimeException(message, cause)

View File

@@ -0,0 +1,5 @@
package com.betriebsratkanzlei.legalconsenthub.error
class FileTooLargeException(
message: String,
) : RuntimeException(message)

View File

@@ -0,0 +1,5 @@
package com.betriebsratkanzlei.legalconsenthub.error
class UnsupportedFileTypeException(
message: String,
) : RuntimeException(message)

View File

@@ -0,0 +1,125 @@
package com.betriebsratkanzlei.legalconsenthub.file
import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotFoundException
import com.betriebsratkanzlei.legalconsenthub.error.FileNotFoundException
import com.betriebsratkanzlei.legalconsenthub.error.FileTooLargeException
import com.betriebsratkanzlei.legalconsenthub.error.UnsupportedFileTypeException
import com.betriebsratkanzlei.legalconsenthub_api.api.FileApi
import com.betriebsratkanzlei.legalconsenthub_api.model.AssociateFilesWithApplicationFormRequest
import com.betriebsratkanzlei.legalconsenthub_api.model.UploadedFileDto
import org.springframework.core.io.ByteArrayResource
import org.springframework.core.io.Resource
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile
import java.util.UUID
@RestController
class FileController(
private val fileService: FileService,
private val fileMapper: FileMapper,
) : FileApi {
@PreAuthorize(
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL', 'EMPLOYEE')",
)
override fun uploadFile(
file: MultipartFile,
formElementReference: String,
organizationId: String?,
applicationFormId: UUID?,
): ResponseEntity<UploadedFileDto> =
try {
val uploadedFile = fileService.uploadFile(file, applicationFormId, formElementReference, organizationId)
ResponseEntity
.status(HttpStatus.CREATED)
.body(fileMapper.toDto(uploadedFile))
} catch (e: FileTooLargeException) {
ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE).build()
} catch (e: UnsupportedFileTypeException) {
ResponseEntity.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE).build()
}
@PreAuthorize(
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL', 'EMPLOYEE')",
)
override fun getFileById(id: UUID): ResponseEntity<UploadedFileDto> =
try {
val uploadedFile = fileService.getFile(id)
ResponseEntity.ok(fileMapper.toDto(uploadedFile))
} catch (e: FileNotFoundException) {
ResponseEntity.notFound().build()
}
@PreAuthorize(
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL', 'EMPLOYEE')",
)
override fun downloadFileContent(
id: UUID,
inline: Boolean,
): ResponseEntity<Resource> =
try {
val (uploadedFile, bytes) = fileService.downloadFile(id)
val resource = ByteArrayResource(bytes)
val disposition = if (inline) "inline" else "attachment"
ResponseEntity
.ok()
.contentType(MediaType.parseMediaType(uploadedFile.mimeType))
.header(
HttpHeaders.CONTENT_DISPOSITION,
"$disposition; filename=\"${uploadedFile.originalFilename}\"",
).body(resource)
} catch (e: FileNotFoundException) {
ResponseEntity.notFound().build()
}
@PreAuthorize(
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL', 'EMPLOYEE')",
)
override fun getFilesByApplicationForm(
applicationFormId: UUID,
formElementReference: String?,
): ResponseEntity<List<UploadedFileDto>> {
val files =
if (formElementReference != null) {
fileService.getFilesByElement(applicationFormId, formElementReference)
} else {
fileService.getFilesByApplicationForm(applicationFormId)
}
return ResponseEntity.ok(files.map { fileMapper.toDto(it) })
}
@PreAuthorize(
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL')",
)
override fun deleteFile(id: UUID): ResponseEntity<Unit> =
try {
fileService.deleteFile(id)
ResponseEntity.noContent().build()
} catch (e: FileNotFoundException) {
ResponseEntity.notFound().build()
}
@PreAuthorize(
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL', 'EMPLOYEE')",
)
override fun associateFilesWithApplicationForm(
applicationFormId: UUID,
associateFilesWithApplicationFormRequest: AssociateFilesWithApplicationFormRequest,
): ResponseEntity<Unit> =
try {
fileService.associateTemporaryFiles(
associateFilesWithApplicationFormRequest.fileIds,
applicationFormId,
)
ResponseEntity.noContent().build()
} catch (e: FileNotFoundException) {
ResponseEntity.notFound().build()
} catch (e: ApplicationFormNotFoundException) {
ResponseEntity.notFound().build()
}
}

View File

@@ -0,0 +1,25 @@
package com.betriebsratkanzlei.legalconsenthub.file
import com.betriebsratkanzlei.legalconsenthub.user.UserMapper
import com.betriebsratkanzlei.legalconsenthub_api.model.UploadedFileDto
import org.springframework.stereotype.Component
import java.time.Instant
@Component
class FileMapper(
private val userMapper: UserMapper,
) {
fun toDto(uploadedFile: UploadedFile): UploadedFileDto =
UploadedFileDto(
id = uploadedFile.id ?: throw IllegalStateException("File ID must not be null"),
filename = uploadedFile.filename,
originalFilename = uploadedFile.originalFilename,
propertySize = uploadedFile.size,
mimeType = uploadedFile.mimeType,
organizationId = uploadedFile.organizationId,
applicationFormId = uploadedFile.applicationForm?.id,
formElementReference = uploadedFile.formElementReference,
uploadedAt = uploadedFile.uploadedAt ?: Instant.now(),
uploadedBy = uploadedFile.uploadedBy?.let { userMapper.toUserDto(it) },
)
}

View File

@@ -0,0 +1,23 @@
package com.betriebsratkanzlei.legalconsenthub.file
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import org.springframework.stereotype.Repository
import java.time.Instant
import java.util.UUID
@Repository
interface FileRepository : JpaRepository<UploadedFile, UUID> {
fun findByApplicationFormId(applicationFormId: UUID): List<UploadedFile>
fun findByApplicationFormIdAndFormElementReference(
applicationFormId: UUID,
formElementReference: String,
): List<UploadedFile>
@Query("SELECT f FROM UploadedFile f WHERE f.isTemporary = true AND f.uploadedAt < :cutoffDate")
fun findTemporaryFilesOlderThan(
@Param("cutoffDate") cutoffDate: Instant,
): List<UploadedFile>
}

View File

@@ -0,0 +1,376 @@
package com.betriebsratkanzlei.legalconsenthub.file
import com.betriebsratkanzlei.legalconsenthub.application_form.ApplicationForm
import com.betriebsratkanzlei.legalconsenthub.application_form.ApplicationFormRepository
import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotFoundException
import com.betriebsratkanzlei.legalconsenthub.error.FileNotFoundException
import com.betriebsratkanzlei.legalconsenthub.error.FileStorageException
import com.betriebsratkanzlei.legalconsenthub.error.FileTooLargeException
import com.betriebsratkanzlei.legalconsenthub.error.UnsupportedFileTypeException
import com.betriebsratkanzlei.legalconsenthub.security.CustomJwtTokenPrincipal
import com.betriebsratkanzlei.legalconsenthub.user.UserRepository
import org.apache.tika.Tika
import org.slf4j.LoggerFactory
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.multipart.MultipartFile
import java.util.UUID
@Service
class FileService(
private val fileRepository: FileRepository,
private val fileStorage: FileStorage,
private val applicationFormRepository: ApplicationFormRepository,
private val userRepository: UserRepository,
) {
private val logger = LoggerFactory.getLogger(FileService::class.java)
private val tika = Tika()
companion object {
private const val MAX_FILE_SIZE = 10 * 1024 * 1024L // 10MB in bytes
private val ALLOWED_MIME_TYPES =
setOf(
"application/pdf",
"application/x-pdf", // PDF variant detected by some tools
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", // DOCX
"application/msword", // DOC
"application/vnd.oasis.opendocument.text", // ODT
"image/jpeg",
"image/png",
"application/zip",
"application/x-zip-compressed",
)
}
fun uploadFile(
file: MultipartFile,
applicationFormId: UUID?,
formElementReference: String,
organizationId: String?,
): UploadedFile {
if (file.size > MAX_FILE_SIZE) {
throw FileTooLargeException(
"File size ${file.size} bytes exceeds maximum allowed size $MAX_FILE_SIZE bytes",
)
}
val detectedMimeType =
try {
tika.detect(file.inputStream)
} catch (e: Exception) {
logger.error("Failed to detect MIME type for file: ${file.originalFilename}", e)
throw UnsupportedFileTypeException("Failed to detect file type")
}
if (detectedMimeType !in ALLOWED_MIME_TYPES) {
logger.warn("Rejected file '${file.originalFilename}' with detected MIME type: $detectedMimeType")
throw UnsupportedFileTypeException("File type $detectedMimeType is not allowed")
}
val sanitizedFilename = sanitizeFilename(file.originalFilename ?: "unnamed")
val uniqueFilename = "${UUID.randomUUID()}_$sanitizedFilename"
// Get application form if ID provided (null for temporary uploads)
val applicationForm =
applicationFormId?.let {
applicationFormRepository
.findById(it)
.orElseThrow { ApplicationFormNotFoundException(it) }
}
val isTemporary = applicationFormId == null
val principal = SecurityContextHolder.getContext().authentication.principal as? CustomJwtTokenPrincipal
val currentUser = principal?.id?.let { userRepository.findById(it).orElse(null) }
val storageKey =
FileStorageKey(
organizationId = organizationId,
applicationFormId = applicationFormId,
formElementReference = formElementReference,
filename = uniqueFilename,
)
val storagePath =
try {
val fileBytes = file.bytes
fileStorage.store(storageKey, fileBytes)
} catch (e: Exception) {
logger.error("Failed to store file: ${file.originalFilename}", e)
throw FileStorageException("Failed to store file", e)
}
val uploadedFile =
UploadedFile(
filename = uniqueFilename,
originalFilename = sanitizedFilename,
size = file.size,
mimeType = detectedMimeType,
organizationId = organizationId,
applicationForm = applicationForm,
formElementReference = formElementReference,
storagePath = storagePath,
uploadedBy = currentUser,
isTemporary = isTemporary,
)
return try {
fileRepository.save(uploadedFile)
} catch (e: Exception) {
// Cleanup storage if database save fails
try {
fileStorage.delete(storageKey)
} catch (cleanupException: Exception) {
logger.error("Failed to cleanup file after database save failure", cleanupException)
}
logger.error("Failed to save file metadata to database", e)
throw FileStorageException("Failed to save file metadata", e)
}
}
fun getFile(id: UUID): UploadedFile =
fileRepository
.findById(id)
.orElseThrow { FileNotFoundException(id) }
fun downloadFile(id: UUID): Pair<UploadedFile, ByteArray> {
val uploadedFile = getFile(id)
val storageKey =
FileStorageKey(
organizationId = uploadedFile.organizationId,
applicationFormId = uploadedFile.applicationForm?.id,
formElementReference = uploadedFile.formElementReference,
filename = uploadedFile.filename,
)
val bytes =
fileStorage.retrieve(storageKey)
?: throw FileNotFoundException(id, "File not found in storage")
return Pair(uploadedFile, bytes)
}
fun getFilesByApplicationForm(applicationFormId: UUID): List<UploadedFile> =
fileRepository.findByApplicationFormId(applicationFormId)
fun getFilesByElement(
applicationFormId: UUID,
formElementReference: String,
): List<UploadedFile> =
fileRepository.findByApplicationFormIdAndFormElementReference(
applicationFormId,
formElementReference,
)
fun deleteFile(id: UUID) {
val uploadedFile = getFile(id)
val storageKey =
FileStorageKey(
organizationId = uploadedFile.organizationId,
applicationFormId = uploadedFile.applicationForm?.id,
formElementReference = uploadedFile.formElementReference,
filename = uploadedFile.filename,
)
try {
fileStorage.delete(storageKey)
} catch (e: Exception) {
logger.error("Failed to delete file from storage: $id", e)
// Continue with database deletion even if storage deletion fails
}
try {
fileRepository.delete(uploadedFile)
} catch (e: Exception) {
logger.error("Failed to delete file from database: $id", e)
throw FileStorageException("Failed to delete file", e)
}
}
/**
* Associates temporary files with already existing application form.
*/
fun associateTemporaryFiles(
fileIds: List<UUID>,
applicationFormId: UUID,
) {
val applicationForm =
applicationFormRepository
.findById(applicationFormId)
.orElseThrow { ApplicationFormNotFoundException(applicationFormId) }
fileIds.forEach { fileId ->
val uploadedFile = getFile(fileId)
if (!uploadedFile.isTemporary) {
return@forEach
}
// Move file from temporary storage to application form storage
val oldStorageKey =
FileStorageKey(
organizationId = uploadedFile.organizationId,
applicationFormId = null,
formElementReference = uploadedFile.formElementReference,
filename = uploadedFile.filename,
)
val newStorageKey =
FileStorageKey(
organizationId = uploadedFile.organizationId,
applicationFormId = applicationFormId,
formElementReference = uploadedFile.formElementReference,
filename = uploadedFile.filename,
)
// Retrieve file from temporary location
val bytes =
fileStorage.retrieve(oldStorageKey)
?: throw FileNotFoundException(fileId, "Temporary file not found in storage")
// Store in new location
val newStoragePath = fileStorage.store(newStorageKey, bytes)
// Delete from old location
fileStorage.delete(oldStorageKey)
// Update database record
uploadedFile.applicationForm = applicationForm
uploadedFile.isTemporary = false
uploadedFile.storagePath = newStoragePath
fileRepository.save(uploadedFile)
}
}
/**
* Associates temporary files with a newly created application form atomically.
* Uses a copy-first pattern with compensating transactions for filesystem safety:
* 1. Copy files to new locations (reversible)
* 2. Update database records (within transaction)
* 3. Delete original files (cleanup)
*
* If any step fails, compensating transactions clean up copied files
* and the database transaction rolls back.
*
*/
@Transactional(rollbackFor = [Exception::class])
fun associateTemporaryFilesTransactional(
fileIds: List<UUID>,
applicationForm: ApplicationForm,
) {
if (fileIds.isEmpty()) {
return
}
// Track moved files for compensating transaction
data class MovedFile(
val oldKey: FileStorageKey,
val newKey: FileStorageKey,
val uploadedFile: UploadedFile,
val newStoragePath: String,
)
val movedFiles = mutableListOf<MovedFile>()
try {
fileIds.forEach { fileId ->
val uploadedFile =
fileRepository
.findById(fileId)
.orElseThrow { FileNotFoundException(fileId) }
if (!uploadedFile.isTemporary) {
logger.debug("Skipping non-temporary file: {}", fileId)
return@forEach
}
val oldStorageKey =
FileStorageKey(
organizationId = uploadedFile.organizationId,
applicationFormId = null,
formElementReference = uploadedFile.formElementReference,
filename = uploadedFile.filename,
)
val newStorageKey =
FileStorageKey(
organizationId = uploadedFile.organizationId,
applicationFormId = applicationForm.id!!,
formElementReference = uploadedFile.formElementReference,
filename = uploadedFile.filename,
)
// Step 1: Copy file to new location (don't delete yet)
val bytes =
fileStorage.retrieve(oldStorageKey)
?: throw FileNotFoundException(fileId, "Temporary file not found in storage")
val newStoragePath = fileStorage.store(newStorageKey, bytes)
movedFiles.add(MovedFile(oldStorageKey, newStorageKey, uploadedFile, newStoragePath))
// Step 2: Update database record (within transaction)
uploadedFile.applicationForm = applicationForm
uploadedFile.isTemporary = false
uploadedFile.storagePath = newStoragePath
fileRepository.save(uploadedFile)
}
// Step 3: All succeeded - delete old files (cleanup)
movedFiles.forEach { movedFile ->
try {
fileStorage.delete(movedFile.oldKey)
} catch (e: Exception) {
// Log but don't fail - cleanup scheduler will handle orphaned files
logger.warn("Failed to delete old temporary file: ${movedFile.oldKey.filename}", e)
}
}
logger.info("Successfully associated ${movedFiles.size} files with application form $applicationForm.id")
} catch (e: Exception) {
// Compensating transaction: cleanup copied files
movedFiles.forEach { movedFile ->
try {
fileStorage.delete(movedFile.newKey)
logger.debug("Cleaned up copied file: ${movedFile.newKey.filename}")
} catch (cleanupException: Exception) {
logger.error(
"Failed to cleanup copied file during rollback: ${movedFile.newKey.filename}",
cleanupException,
)
}
}
logger.error("Failed to associate temporary files with application form $applicationForm.id", e)
throw e
}
}
fun deleteTemporaryFilesOlderThan(days: Long) {
val cutoffDate =
java.time.Instant
.now()
.minus(days, java.time.temporal.ChronoUnit.DAYS)
val temporaryFiles = fileRepository.findTemporaryFilesOlderThan(cutoffDate)
logger.info("Found ${temporaryFiles.size} temporary files older than $days days")
temporaryFiles.forEach { file ->
try {
deleteFile(file.id!!)
logger.info("Deleted temporary file: ${file.id}")
} catch (e: Exception) {
logger.error("Failed to delete temporary file: ${file.id}", e)
}
}
}
private fun sanitizeFilename(filename: String): String {
// Remove path separators and limit length
return filename
.replace(Regex("[/\\\\]"), "_")
.replace(Regex("\\.\\./"), "_")
.take(255)
}
}

View File

@@ -0,0 +1,29 @@
package com.betriebsratkanzlei.legalconsenthub.file
import java.util.UUID
data class FileStorageKey(
val organizationId: String?,
val applicationFormId: UUID?,
val formElementReference: String,
val filename: String,
) {
fun toPathParts(): List<String> {
val orgId = organizationId ?: "global"
val formId = applicationFormId?.toString() ?: "temporary"
return listOf(orgId, formId, formElementReference, filename)
}
}
interface FileStorage {
fun store(
key: FileStorageKey,
bytes: ByteArray,
): String
fun retrieve(key: FileStorageKey): ByteArray?
fun delete(key: FileStorageKey): Boolean
fun exists(key: FileStorageKey): Boolean
}

View File

@@ -0,0 +1,33 @@
package com.betriebsratkanzlei.legalconsenthub.file
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(FileStorageProperties::class)
class FileStorageConfiguration
@ConfigurationProperties(prefix = "legalconsenthub.file.storage")
data class FileStorageProperties(
@NestedConfigurationProperty
val filesystem: FileSystemProperties = FileSystemProperties(),
) {
data class FileSystemProperties(
/**
* Base directory for uploaded files. In development this defaults to a folder next to the backend code.
*
* Configure either via application.yaml:
* legalconsenthub:
* file:
* storage:
* filesystem:
* base-dir: /var/lib/legalconsenthub/files
*
* or via environment variable:
* LEGALCONSENTHUB_FILE_STORAGE_FILESYSTEM_BASE_DIR=/var/lib/legalconsenthub/files
*/
val baseDir: String = "./data/files",
)
}

View File

@@ -0,0 +1,78 @@
package com.betriebsratkanzlei.legalconsenthub.file
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 FileSystemFileStorage(
private val properties: FileStorageProperties,
) : FileStorage {
private val logger = LoggerFactory.getLogger(FileSystemFileStorage::class.java)
override fun store(
key: FileStorageKey,
bytes: ByteArray,
): String {
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
}
}
return targetPath.toString()
}
override fun retrieve(key: FileStorageKey): ByteArray? {
val path = resolvePath(key)
if (!path.exists()) return null
return path.inputStream().use { it.readBytes() }
}
override fun delete(key: FileStorageKey): Boolean {
val path = resolvePath(key)
return try {
Files.deleteIfExists(path)
} catch (e: Exception) {
logger.error("Failed to delete file at $path", e)
false
}
}
override fun exists(key: FileStorageKey): Boolean {
val path = resolvePath(key)
return path.exists()
}
private fun resolvePath(key: FileStorageKey): Path {
val baseDir = Path.of(properties.filesystem.baseDir)
return key.toPathParts().fold(baseDir) { acc, part -> acc.resolve(part) }
}
}

View File

@@ -0,0 +1,27 @@
package com.betriebsratkanzlei.legalconsenthub.file
import org.slf4j.LoggerFactory
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Component
@Component
class TemporaryFileCleanupScheduler(
private val fileService: FileService,
) {
private val logger = LoggerFactory.getLogger(TemporaryFileCleanupScheduler::class.java)
/**
* Delete temporary files older than 7 days
* Runs daily at 2 AM
*/
@Scheduled(cron = "0 0 2 * * *")
fun cleanupTemporaryFiles() {
logger.info("Starting temporary file cleanup job")
try {
fileService.deleteTemporaryFilesOlderThan(7)
logger.info("Temporary file cleanup job completed successfully")
} catch (e: Exception) {
logger.error("Temporary file cleanup job failed", e)
}
}
}

View File

@@ -0,0 +1,50 @@
package com.betriebsratkanzlei.legalconsenthub.file
import com.betriebsratkanzlei.legalconsenthub.application_form.ApplicationForm
import com.betriebsratkanzlei.legalconsenthub.user.User
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.EntityListeners
import jakarta.persistence.GeneratedValue
import jakarta.persistence.Id
import jakarta.persistence.JoinColumn
import jakarta.persistence.ManyToOne
import jakarta.persistence.Table
import org.springframework.data.annotation.CreatedDate
import org.springframework.data.jpa.domain.support.AuditingEntityListener
import java.time.Instant
import java.util.UUID
@Entity
@EntityListeners(AuditingEntityListener::class)
@Table(name = "uploaded_file")
class UploadedFile(
@Id
@GeneratedValue
var id: UUID? = null,
@Column(nullable = false)
var filename: String,
@Column(nullable = false)
var originalFilename: String,
@Column(nullable = false)
var size: Long,
@Column(nullable = false)
var mimeType: String,
@Column(nullable = true)
var organizationId: String? = null,
@ManyToOne
@JoinColumn(name = "application_form_id", nullable = true)
var applicationForm: ApplicationForm? = null,
@Column(nullable = false)
var formElementReference: String,
@Column(nullable = false)
var storagePath: String,
@ManyToOne
@JoinColumn(name = "uploaded_by_id", nullable = true)
var uploadedBy: User? = null,
@CreatedDate
@Column(nullable = false)
var uploadedAt: Instant? = null,
@Column(nullable = false)
var isTemporary: Boolean = false,
)

View File

@@ -57,6 +57,17 @@ spring:
timeout: 5000 timeout: 5000
writetimeout: 5000 writetimeout: 5000
servlet:
multipart:
max-file-size: 10MB
max-request-size: 50MB
legalconsenthub:
file:
storage:
filesystem:
base-dir: ${LEGALCONSENTHUB_FILE_STORAGE_FILESYSTEM_BASE_DIR:./data/files}
management: management:
health: health:
mail: mail:

View File

@@ -82,7 +82,7 @@ create table form_element
form_element_order integer, form_element_order integer,
is_clonable boolean not null, is_clonable boolean not null,
row_preset_filter_src_col_idx integer, row_preset_filter_src_col_idx integer,
type smallint not null check (type between 0 and 8), type smallint not null check (type between 0 and 9),
form_element_sub_section_id uuid not null, form_element_sub_section_id uuid not null,
id uuid not null, id uuid not null,
description varchar(255), description varchar(255),
@@ -152,6 +152,23 @@ create table table_column_mappings
form_element_id uuid not null form_element_id uuid not null
); );
create table uploaded_file
(
is_temporary boolean not null,
size bigint not null,
uploaded_at timestamp(6) with time zone not null,
application_form_id uuid,
id uuid not null,
filename varchar(255) not null,
form_element_reference varchar(255) not null,
mime_type varchar(255) not null,
organization_id varchar(255),
original_filename varchar(255) not null,
storage_path varchar(255) not null,
uploaded_by_id varchar(255),
primary key (id)
);
create table visibility_conditions create table visibility_conditions
( (
form_element_id uuid not null, form_element_id uuid not null,
@@ -233,6 +250,16 @@ alter table if exists table_column_mappings
foreign key (form_element_id) foreign key (form_element_id)
references form_element; references form_element;
alter table if exists uploaded_file
add constraint FKn866ru0c9ygi5wsqvliv181uj
foreign key (application_form_id)
references application_form;
alter table if exists uploaded_file
add constraint FKtg323a9339lx0do79gu4eftao
foreign key (uploaded_by_id)
references app_user;
alter table if exists visibility_conditions alter table if exists visibility_conditions
add constraint FK5xuf7bd179ogpq5a1m3g8q7jb add constraint FK5xuf7bd179ogpq5a1m3g8q7jb
foreign key (form_element_id) foreign key (form_element_id)

View File

@@ -4839,3 +4839,14 @@ formElementSections:
label: Nein label: Nein
processingPurpose: DATA_ANALYSIS processingPurpose: DATA_ANALYSIS
employeeDataCategory: SENSITIVE employeeDataCategory: SENSITIVE
- reference: ki_dokumentation
title: Dokumentation zur Grundrechte-Folgenabschätzung
description: Bitte laden Sie die Dokumentation zur Grundrechte-Folgenabschätzung hoch
type: FILE_UPLOAD
visibilityConditions:
- formElementConditionType: SHOW
sourceFormElementReference: ki_info_grundrechte_folgenabschaetzung
formElementExpectedValue: Ja (bitte beilegen)
formElementOperator: EQUALS
options: []

View File

@@ -74,6 +74,7 @@ pnpm run api:generate
- Use TypeScript interfaces for props and emits - Use TypeScript interfaces for props and emits
- Follow Vue 3 Composition API patterns - Follow Vue 3 Composition API patterns
- Use Nuxt UI components for consistent design - Use Nuxt UI components for consistent design
- API calls are wrapped in composables or Pinia actions. The `/composables/applicationFormTemplate` are a good reference.
### i18n Best Practices ### i18n Best Practices

View File

@@ -15,6 +15,8 @@
:disabled="props.disabled" :disabled="props.disabled"
:all-form-elements="props.allFormElements" :all-form-elements="props.allFormElements"
:table-row-preset="formElementItem.formElement.tableRowPreset" :table-row-preset="formElementItem.formElement.tableRowPreset"
:application-form-id="props.applicationFormId"
:form-element-reference="formElementItem.formElement.reference"
@update:form-options="updateFormOptions($event, formElementItem)" @update:form-options="updateFormOptions($event, formElementItem)"
/> />
<div v-if="formElementItem.formElement.isClonable && !props.disabled" class="mt-3"> <div v-if="formElementItem.formElement.isClonable && !props.disabled" class="mt-3">
@@ -171,6 +173,8 @@ function getResolvedComponent(formElement: FormElementDto) {
return resolveComponent('TheDate') return resolveComponent('TheDate')
case 'TABLE': case 'TABLE':
return resolveComponent('TheTable') return resolveComponent('TheTable')
case 'FILE_UPLOAD':
return resolveComponent('TheFileUpload')
default: default:
return resolveComponent('Unimplemented') return resolveComponent('Unimplemented')
} }

View File

@@ -0,0 +1,234 @@
<template>
<div class="space-y-4">
<!-- Upload Area -->
<div>
<UFileUpload
v-model="selectedFiles"
:accept="allowedFileTypes"
:multiple="true"
:disabled="isUploading || disabled"
:label="t('applicationForms.formElements.fileUpload.label')"
:description="t('applicationForms.formElements.fileUpload.allowedTypes')"
variant="area"
layout="list"
position="inside"
@change="handleFileSelect"
/>
</div>
<!-- Upload Progress -->
<div v-if="isUploading" class="space-y-2">
<UProgress :value="uploadProgress" />
<p class="text-sm text-gray-600">
{{ t('applicationForms.formElements.fileUpload.uploading') }}
</p>
</div>
<!-- Error Message -->
<UAlert
v-if="errorMessage"
color="error"
variant="soft"
:title="t('applicationForms.formElements.fileUpload.uploadError')"
:description="errorMessage"
:close-button="{ icon: 'i-ph-x', color: 'red', variant: 'link' }"
@close="errorMessage = ''"
/>
<!-- Uploaded Files List -->
<div v-if="uploadedFiles.length > 0" class="space-y-2">
<p class="text-sm font-medium">
{{ t('applicationForms.formElements.fileUpload.uploadedFiles') }}
</p>
<div class="space-y-2">
<div
v-for="file in uploadedFiles"
:key="file.fileId"
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
>
<div class="flex items-center gap-3 flex-1 min-w-0">
<UIcon :name="getFileIcon(file.mimeType)" class="text-xl flex-shrink-0" />
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate">{{ file.filename }}</p>
<p class="text-xs text-gray-500">{{ formatFileSize(file.size) }}</p>
</div>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<UButton
v-if="isViewableInBrowser(file.mimeType)"
icon="i-ph-eye"
color="neutral"
variant="ghost"
size="sm"
:title="t('applicationForms.formElements.fileUpload.view')"
@click="viewFile(file.fileId)"
/>
<UButton
icon="i-ph-download"
color="info"
variant="ghost"
size="sm"
:disabled="isDownloading"
@click="downloadFile(file.fileId, file.filename)"
/>
<UButton
icon="i-ph-trash"
color="error"
variant="ghost"
size="sm"
:disabled="disabled || isDeleting"
@click="deleteFile(file.fileId)"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { EmployeeDataCategory, type FormOptionDto, ProcessingPurpose } from '~~/.api-client'
import { useFile, type UploadedFileMetadata } from '~/composables/file/useFile'
import { useUserStore } from '~~/stores/useUserStore'
const props = defineProps<{
formOptions: FormOptionDto[]
disabled?: boolean
applicationFormId?: string
formElementReference?: string
}>()
const emit = defineEmits<{
(e: 'update:formOptions', value: FormOptionDto[]): void
}>()
const { t } = useI18n()
const {
uploadFile: uploadFileApi,
downloadFile: downloadFileApi,
viewFile: viewFileApi,
deleteFile: deleteFileApi,
parseUploadedFiles,
createFileMetadata,
getFileIcon,
formatFileSize,
isViewableInBrowser
} = useFile()
const isUploading = ref(false)
const isDownloading = ref(false)
const isDeleting = ref(false)
const uploadProgress = ref(0)
const errorMessage = ref('')
const selectedFiles = ref<File[] | null>(null)
const allowedFileTypes = '.pdf,.docx,.doc,.odt,.jpg,.jpeg,.png,.zip'
// Parse uploaded files from formOptions
const uploadedFiles = computed<UploadedFileMetadata[]>(() => {
const values = props.formOptions.map((option) => option.value)
return parseUploadedFiles(values)
})
const userStore = useUserStore()
const organizationId = computed(() => userStore.selectedOrganization?.id)
const handleFileSelect = async () => {
if (!selectedFiles.value || selectedFiles.value.length === 0) return
const files = Array.isArray(selectedFiles.value) ? selectedFiles.value : [selectedFiles.value]
errorMessage.value = ''
for (const file of files) {
const maxFileSize = 10 * 1024 * 1024 // 10 MB
if (file.size > maxFileSize) {
errorMessage.value = t('applicationForms.formElements.fileUpload.fileTooLarge', {
filename: file.name,
maxSize: formatFileSize(maxFileSize)
})
continue
}
await uploadFile(file)
}
selectedFiles.value = null
}
const uploadFile = async (file: File) => {
if (!props.formElementReference) {
errorMessage.value = 'Missing required context: formElementReference'
return
}
isUploading.value = true
uploadProgress.value = 0
try {
const response = await uploadFileApi({
file,
applicationFormId: props.applicationFormId,
formElementReference: props.formElementReference,
organizationId: organizationId.value
})
const metadata = createFileMetadata(response)
const newOption: FormOptionDto = {
value: JSON.stringify(metadata),
label: props.formElementReference,
processingPurpose: props.formOptions[0]?.processingPurpose ?? ProcessingPurpose.None,
employeeDataCategory: props.formOptions[0]?.employeeDataCategory ?? EmployeeDataCategory.None
}
const updatedOptions = [...props.formOptions, newOption]
emit('update:formOptions', updatedOptions)
uploadProgress.value = 100
} catch (error: unknown) {
errorMessage.value = error instanceof Error ? error.message : String(error)
} finally {
isUploading.value = false
uploadProgress.value = 0
}
}
const downloadFile = async (fileId: string, filename: string) => {
isDownloading.value = true
try {
await downloadFileApi(fileId, filename)
} catch (error: unknown) {
errorMessage.value = error instanceof Error ? error.message : String(error)
} finally {
isDownloading.value = false
}
}
const viewFile = (fileId: string) => {
viewFileApi(fileId)
}
const deleteFile = async (fileId: string) => {
if (!confirm(t('common.confirmDelete'))) return
isDeleting.value = true
try {
await deleteFileApi(fileId)
// Remove from formOptions
const updatedOptions = props.formOptions.filter((option) => {
try {
const metadata = JSON.parse(option.value) as UploadedFileMetadata
return metadata.fileId !== fileId
} catch {
return true
}
})
emit('update:formOptions', updatedOptions)
} catch (error: unknown) {
errorMessage.value = error instanceof Error ? error.message : String(error)
} finally {
isDeleting.value = false
}
}
</script>

View File

@@ -6,9 +6,56 @@ export function useApplicationForm() {
const applicationFormApi = useApplicationFormApi() const applicationFormApi = useApplicationFormApi()
const logger = useLogger().withTag('applicationForm') const logger = useLogger().withTag('applicationForm')
/**
* Extract all file IDs from FILE_UPLOAD form elements
*/
function extractFileIdsFromForm(applicationFormDto: ApplicationFormDto): string[] {
const fileIds: string[] = []
applicationFormDto.formElementSections?.forEach((section) => {
section.formElementSubSections?.forEach((subsection) => {
subsection.formElements?.forEach((element) => {
if (element.type === 'FILE_UPLOAD') {
element.options?.forEach((option) => {
try {
const metadata = JSON.parse(option.value)
if (metadata.fileId) {
fileIds.push(metadata.fileId)
}
} catch {
// Ignore parsing errors
}
})
}
})
})
})
return fileIds
}
/**
* Creates an application form with atomic file association.
*
* File IDs are included in the DTO and associated atomically on the backend.
* If file association fails, the entire operation rolls back (form is not created).
*/
async function createApplicationForm(applicationFormDto: ApplicationFormDto): Promise<ApplicationFormDto> { async function createApplicationForm(applicationFormDto: ApplicationFormDto): Promise<ApplicationFormDto> {
try { try {
return await applicationFormApi.createApplicationForm(applicationFormDto) // Extract all temporary file IDs and include them in the DTO for atomic association
const fileIds = extractFileIdsFromForm(applicationFormDto)
// Single atomic API call - backend handles form creation and file association transactionally
const createdForm = await applicationFormApi.createApplicationForm({
...applicationFormDto,
fileIds: fileIds.length > 0 ? fileIds : undefined
})
if (fileIds.length > 0) {
logger.debug(`Created form ${createdForm.id} with ${fileIds.length} files atomically associated`)
}
return createdForm
} catch (e: unknown) { } catch (e: unknown) {
logger.error('Failed creating application form:', e) logger.error('Failed creating application form:', e)
return Promise.reject(e) return Promise.reject(e)

View File

@@ -0,0 +1,150 @@
import type { UploadedFileDto } from '~~/.api-client'
import { useFileApi } from './useFileApi'
import { useLogger } from '../useLogger'
export interface UploadedFileMetadata {
fileId: string
filename: string
size: number
mimeType: string
uploadedAt: string
}
export function useFile() {
const fileApi = useFileApi()
const logger = useLogger().withTag('file')
const { t } = useI18n()
async function uploadFile(params: {
file: File
applicationFormId?: string
formElementReference: string
organizationId?: string
}): Promise<UploadedFileDto> {
try {
logger.debug('Uploading file:', params.file.name)
return await fileApi.uploadFile(params)
} catch (e: unknown) {
logger.error('Failed uploading file:', e)
// Enhanced error handling with user-friendly messages
if (e && typeof e === 'object' && 'status' in e) {
const error = e as { status: number }
if (error.status === 413) {
return Promise.reject(
new Error(
t('applicationForms.formElements.fileUpload.fileTooLarge', {
filename: params.file.name,
maxSize: '10MB'
})
)
)
} else if (error.status === 415) {
return Promise.reject(new Error(t('applicationForms.formElements.fileUpload.unsupportedType')))
}
}
return Promise.reject(new Error(t('applicationForms.formElements.fileUpload.uploadFailed')))
}
}
async function downloadFile(id: string, filename: string): Promise<void> {
try {
logger.debug('Downloading file:', id)
const blob = await fileApi.downloadFileContent(id)
// Create download link
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
} catch (e: unknown) {
logger.error('Failed downloading file:', e)
return Promise.reject(new Error(t('applicationForms.formElements.fileUpload.downloadFailed')))
}
}
function isViewableInBrowser(mimeType: string): boolean {
return mimeType === 'application/pdf' || mimeType.startsWith('image/')
}
function viewFile(id: string): void {
const url = fileApi.getFileViewUrl(id)
window.open(url, '_blank')
}
async function deleteFile(id: string): Promise<void> {
try {
logger.debug('Deleting file:', id)
return await fileApi.deleteFile(id)
} catch (e: unknown) {
logger.error('Failed deleting file:', e)
return Promise.reject(new Error(t('applicationForms.formElements.fileUpload.deleteFailed')))
}
}
async function associateFilesWithApplicationForm(applicationFormId: string, fileIds: string[]): Promise<void> {
try {
logger.debug('Associating files with application form:', { applicationFormId, fileIds })
return await fileApi.associateFilesWithApplicationForm(applicationFormId, fileIds)
} catch (e: unknown) {
logger.error('Failed associating files with application form:', e)
return Promise.reject(e)
}
}
function parseUploadedFiles(formOptionsValues: string[]): UploadedFileMetadata[] {
return formOptionsValues
.map((value) => {
try {
return JSON.parse(value) as UploadedFileMetadata
} catch {
return null
}
})
.filter((file): file is UploadedFileMetadata => file !== null)
}
function createFileMetadata(response: UploadedFileDto): UploadedFileMetadata {
return {
fileId: response.id,
filename: response.originalFilename,
size: response.size,
mimeType: response.mimeType,
uploadedAt: response.uploadedAt.toISOString()
}
}
function getFileIcon(mimeType: string): string {
if (mimeType.startsWith('image/')) return 'i-ph-image'
if (mimeType === 'application/pdf') return 'i-ph-file-pdf'
if (mimeType.includes('word')) return 'i-ph-file-doc'
if (mimeType.includes('zip')) return 'i-ph-file-zip'
return 'i-ph-file'
}
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`
}
return {
uploadFile,
downloadFile,
viewFile,
deleteFile,
associateFilesWithApplicationForm,
parseUploadedFiles,
createFileMetadata,
getFileIcon,
formatFileSize,
isViewableInBrowser
}
}

View File

@@ -0,0 +1,64 @@
import {
FileApi,
Configuration,
type UploadedFileDto,
type AssociateFilesWithApplicationFormRequest
} from '~~/.api-client'
import { cleanDoubleSlashes, withoutTrailingSlash } from 'ufo'
import { wrappedFetchWrap } from '~/utils/wrappedFetch'
export function useFileApi() {
const appBaseUrl = useRuntimeConfig().app.baseURL
const { serverApiBasePath, clientProxyBasePath } = useRuntimeConfig().public
const basePath = withoutTrailingSlash(
cleanDoubleSlashes(import.meta.client ? appBaseUrl + clientProxyBasePath : clientProxyBasePath + serverApiBasePath)
)
const fileApiClient = new FileApi(new Configuration({ basePath, fetchApi: wrappedFetchWrap(useRequestFetch()) }))
async function uploadFile(params: {
file: File
applicationFormId?: string
formElementReference: string
organizationId?: string
}): Promise<UploadedFileDto> {
return fileApiClient.uploadFile(params)
}
async function downloadFileContent(id: string, inline = false): Promise<Blob> {
return fileApiClient.downloadFileContent({ id, inline })
}
function getFileViewUrl(id: string): string {
return `${basePath}/files/${id}/content?inline=true`
}
async function deleteFile(id: string): Promise<void> {
return fileApiClient.deleteFile({ id })
}
async function getFilesByApplicationForm(
applicationFormId: string,
formElementReference?: string
): Promise<UploadedFileDto[]> {
return fileApiClient.getFilesByApplicationForm({ applicationFormId, formElementReference })
}
async function associateFilesWithApplicationForm(applicationFormId: string, fileIds: string[]): Promise<void> {
const associateFilesWithApplicationFormRequest: AssociateFilesWithApplicationFormRequest = { fileIds }
return fileApiClient.associateFilesWithApplicationForm({
applicationFormId,
associateFilesWithApplicationFormRequest
})
}
return {
uploadFile,
downloadFileContent,
getFileViewUrl,
deleteFile,
getFilesByApplicationForm,
associateFilesWithApplicationForm
}
}

View File

@@ -32,6 +32,23 @@
"selectValue": "Wert auswählen", "selectValue": "Wert auswählen",
"enlargeTable": "Tabelle vergrößern", "enlargeTable": "Tabelle vergrößern",
"editTable": "Tabelle bearbeiten" "editTable": "Tabelle bearbeiten"
},
"fileUpload": {
"label": "Dateien hochladen",
"dropFiles": "Dateien hier ablegen",
"orClickToUpload": "oder klicken Sie, um Dateien auszuwählen",
"selectFiles": "Dateien auswählen",
"allowedTypes": "Erlaubt: PDF, DOCX, DOC, ODT, JPG, PNG, ZIP (max. 10MB pro Datei)",
"uploadedFiles": "Hochgeladene Dateien",
"uploading": "Wird hochgeladen...",
"uploadError": "Upload-Fehler",
"uploadFailed": "Datei-Upload fehlgeschlagen. Bitte versuchen Sie es erneut.",
"fileTooLarge": "Die Datei \"{filename}\" ist zu groß. Maximale Größe: {maxSize}",
"unsupportedType": "Dieser Dateityp wird nicht unterstützt.",
"deleteFailed": "Datei konnte nicht gelöscht werden.",
"downloadFailed": "Datei konnte nicht heruntergeladen werden.",
"viewFailed": "Datei konnte nicht zur Ansicht geöffnet werden.",
"view": "Ansehen"
} }
}, },
"status": { "status": {
@@ -190,6 +207,7 @@
"save": "Speichern", "save": "Speichern",
"cancel": "Abbrechen", "cancel": "Abbrechen",
"delete": "Löschen", "delete": "Löschen",
"confirmDelete": "Möchten Sie dies wirklich löschen?",
"edit": "Bearbeiten", "edit": "Bearbeiten",
"close": "Schließen", "close": "Schließen",
"confirm": "Bestätigen", "confirm": "Bestätigen",

View File

@@ -32,6 +32,23 @@
"selectValue": "Select value", "selectValue": "Select value",
"enlargeTable": "Enlarge table", "enlargeTable": "Enlarge table",
"editTable": "Edit table" "editTable": "Edit table"
},
"fileUpload": {
"label": "Upload files",
"dropFiles": "Drop files here",
"orClickToUpload": "or click to select files",
"selectFiles": "Select files",
"allowedTypes": "Allowed: PDF, DOCX, DOC, ODT, JPG, PNG, ZIP (max. 10MB per file)",
"uploadedFiles": "Uploaded files",
"uploading": "Uploading...",
"uploadError": "Upload error",
"uploadFailed": "File upload failed. Please try again.",
"fileTooLarge": "The file \"{filename}\" is too large. Maximum size: {maxSize}",
"unsupportedType": "This file type is not supported.",
"deleteFailed": "Could not delete file.",
"downloadFailed": "Could not download file.",
"viewFailed": "Could not open file for viewing.",
"view": "View"
} }
}, },
"status": { "status": {
@@ -190,6 +207,7 @@
"save": "Save", "save": "Save",
"cancel": "Cancel", "cancel": "Cancel",
"delete": "Delete", "delete": "Delete",
"confirmDelete": "Do you really want to delete this?",
"edit": "Edit", "edit": "Edit",
"close": "Close", "close": "Close",
"confirm": "Confirm", "confirm": "Confirm",