diff --git a/api/legalconsenthub.yml b/api/legalconsenthub.yml index 605e428..75e62e0 100644 --- a/api/legalconsenthub.yml +++ b/api/legalconsenthub.yml @@ -1043,18 +1043,8 @@ paths: ####### Files ####### /files: - get: - summary: Get all files - operationId: getAllFiles - tags: - - file - responses: - "200": - description: Successful response - "500": - description: Internal server error post: - summary: Upload a new file + summary: Upload a new file for a form element operationId: uploadFile tags: - file @@ -1063,18 +1053,24 @@ paths: content: multipart/form-data: schema: - $ref: "#/components/schemas/UploadFileDto" + $ref: "#/components/schemas/UploadFileRequestDto" responses: "201": - description: File uploaded + description: File uploaded successfully + content: + application/json: + schema: + $ref: "#/components/schemas/UploadedFileDto" "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" + "413": + description: File too large (exceeds 10MB limit) + "415": + description: Unsupported file type "500": $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}: parameters: @@ -1085,25 +1081,23 @@ paths: type: string format: uuid get: - summary: Get a specific file + summary: Get file metadata by ID operationId: getFileById tags: - file responses: "200": - description: Get file by ID + description: File metadata content: application/json: schema: - $ref: "#/components/schemas/FileDto" - "400": - $ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/BadRequest" + $ref: "#/components/schemas/UploadedFileDto" + "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" - "503": - $ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServiceUnavailable" delete: summary: Delete a file operationId: deleteFile @@ -1116,10 +1110,123 @@ paths: $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: 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": $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: securitySchemes: @@ -1177,6 +1284,14 @@ components: nullable: true readOnly: true 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: type: object @@ -1574,6 +1689,7 @@ components: - RICH_TEXT - DATE - TABLE + - FILE_UPLOAD FormElementVisibilityCondition: type: object @@ -1880,41 +1996,79 @@ components: - WARNING - ERROR - ####### FileDto ####### - FileDto: + ####### File Upload DTOs ####### + UploadedFileDto: type: object required: - id - - name - - file - - createdAt - - modifiedAt + - filename + - originalFilename + - size + - mimeType + - formElementReference + - uploadedAt properties: id: type: string format: uuid - name: + description: Unique identifier for the uploaded file + filename: type: string - file: + description: Unique filename stored on disk (UUID-based) + originalFilename: type: string - createdAt: - type: string - format: date-time - modifiedAt: + description: Original filename provided by the user + size: + type: integer + 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 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 required: - - name - file + - formElementReference properties: - name: - type: string file: type: string 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 ####### ProcessingPurpose: diff --git a/legalconsenthub-backend/build.gradle b/legalconsenthub-backend/build.gradle index 5655bcf..7f030e0 100644 --- a/legalconsenthub-backend/build.gradle +++ b/legalconsenthub-backend/build.gradle @@ -36,6 +36,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'org.springframework.boot:spring-boot-starter-actuator' implementation 'org.springframework.boot:spring-boot-starter-mail' + implementation 'org.apache.tika:tika-core:2.9.1' runtimeOnly 'com.h2database:h2' implementation 'org.postgresql:postgresql' implementation 'org.springframework.boot:spring-boot-testcontainers' 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 52d0cdf..82000a3 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 @@ -133,6 +133,27 @@ class ApplicationFormFormatService( "SWITCH" -> { 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" -> { renderTableValue(element).first } diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form/ApplicationFormService.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form/ApplicationFormService.kt index 9f896bc..3bc6c7e 100644 --- a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form/ApplicationFormService.kt +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form/ApplicationFormService.kt @@ -10,6 +10,7 @@ import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotCreatedExc import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotDeletedException import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotFoundException import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotUpdatedException +import com.betriebsratkanzlei.legalconsenthub.file.FileService import com.betriebsratkanzlei.legalconsenthub.form_element.FormElementMapper import com.betriebsratkanzlei.legalconsenthub.notification.NotificationService 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.PageRequest import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional import java.util.UUID data class ApplicationFormPageWithCommentCounts( @@ -39,7 +41,9 @@ class ApplicationFormService( private val userService: UserService, private val eventPublisher: ApplicationEventPublisher, private val commentRepository: CommentRepository, + private val fileService: FileService, ) { + @Transactional(rollbackFor = [Exception::class]) fun createApplicationForm(applicationFormDto: ApplicationFormDto): ApplicationForm { val applicationForm = applicationFormMapper.toNewApplicationForm(applicationFormDto) val savedApplicationForm: ApplicationForm @@ -49,6 +53,15 @@ class ApplicationFormService( throw ApplicationFormNotCreatedException(e) } + // Associate files atomically if provided + val fileIds = applicationFormDto.fileIds + if (!fileIds.isNullOrEmpty()) { + fileService.associateTemporaryFilesTransactional( + fileIds, + savedApplicationForm, + ) + } + val currentUser = userService.getCurrentUser() versionService.createVersion(savedApplicationForm, currentUser) diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/error/FileNotFoundException.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/error/FileNotFoundException.kt new file mode 100644 index 0000000..e414726 --- /dev/null +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/error/FileNotFoundException.kt @@ -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) diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/error/FileStorageException.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/error/FileStorageException.kt new file mode 100644 index 0000000..f585830 --- /dev/null +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/error/FileStorageException.kt @@ -0,0 +1,6 @@ +package com.betriebsratkanzlei.legalconsenthub.error + +class FileStorageException( + message: String, + cause: Throwable? = null, +) : RuntimeException(message, cause) diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/error/FileTooLargeException.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/error/FileTooLargeException.kt new file mode 100644 index 0000000..918c143 --- /dev/null +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/error/FileTooLargeException.kt @@ -0,0 +1,5 @@ +package com.betriebsratkanzlei.legalconsenthub.error + +class FileTooLargeException( + message: String, +) : RuntimeException(message) diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/error/UnsupportedFileTypeException.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/error/UnsupportedFileTypeException.kt new file mode 100644 index 0000000..897d9e8 --- /dev/null +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/error/UnsupportedFileTypeException.kt @@ -0,0 +1,5 @@ +package com.betriebsratkanzlei.legalconsenthub.error + +class UnsupportedFileTypeException( + message: String, +) : RuntimeException(message) diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/file/FileController.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/file/FileController.kt new file mode 100644 index 0000000..7201453 --- /dev/null +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/file/FileController.kt @@ -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 = + 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 = + 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 = + 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> { + 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 = + 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 = + try { + fileService.associateTemporaryFiles( + associateFilesWithApplicationFormRequest.fileIds, + applicationFormId, + ) + ResponseEntity.noContent().build() + } catch (e: FileNotFoundException) { + ResponseEntity.notFound().build() + } catch (e: ApplicationFormNotFoundException) { + ResponseEntity.notFound().build() + } +} diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/file/FileMapper.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/file/FileMapper.kt new file mode 100644 index 0000000..43b54e2 --- /dev/null +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/file/FileMapper.kt @@ -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) }, + ) +} diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/file/FileRepository.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/file/FileRepository.kt new file mode 100644 index 0000000..7c0df02 --- /dev/null +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/file/FileRepository.kt @@ -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 { + fun findByApplicationFormId(applicationFormId: UUID): List + + fun findByApplicationFormIdAndFormElementReference( + applicationFormId: UUID, + formElementReference: String, + ): List + + @Query("SELECT f FROM UploadedFile f WHERE f.isTemporary = true AND f.uploadedAt < :cutoffDate") + fun findTemporaryFilesOlderThan( + @Param("cutoffDate") cutoffDate: Instant, + ): List +} diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/file/FileService.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/file/FileService.kt new file mode 100644 index 0000000..5444a16 --- /dev/null +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/file/FileService.kt @@ -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 { + 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 = + fileRepository.findByApplicationFormId(applicationFormId) + + fun getFilesByElement( + applicationFormId: UUID, + formElementReference: String, + ): List = + 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, + 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, + 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() + + 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) + } +} diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/file/FileStorage.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/file/FileStorage.kt new file mode 100644 index 0000000..8c7dbe8 --- /dev/null +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/file/FileStorage.kt @@ -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 { + 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 +} diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/file/FileStorageProperties.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/file/FileStorageProperties.kt new file mode 100644 index 0000000..04c8338 --- /dev/null +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/file/FileStorageProperties.kt @@ -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", + ) +} diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/file/FileSystemFileStorage.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/file/FileSystemFileStorage.kt new file mode 100644 index 0000000..6457da3 --- /dev/null +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/file/FileSystemFileStorage.kt @@ -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) } + } +} diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/file/TemporaryFileCleanupScheduler.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/file/TemporaryFileCleanupScheduler.kt new file mode 100644 index 0000000..bd74ec2 --- /dev/null +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/file/TemporaryFileCleanupScheduler.kt @@ -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) + } + } +} diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/file/UploadedFile.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/file/UploadedFile.kt new file mode 100644 index 0000000..d9c7562 --- /dev/null +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/file/UploadedFile.kt @@ -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, +) diff --git a/legalconsenthub-backend/src/main/resources/application.yaml b/legalconsenthub-backend/src/main/resources/application.yaml index f27d294..d29dfc0 100644 --- a/legalconsenthub-backend/src/main/resources/application.yaml +++ b/legalconsenthub-backend/src/main/resources/application.yaml @@ -57,6 +57,17 @@ spring: timeout: 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: health: mail: diff --git a/legalconsenthub-backend/src/main/resources/db/migrations/001-schema.sql b/legalconsenthub-backend/src/main/resources/db/migrations/001-schema.sql index 748470a..82fca24 100644 --- a/legalconsenthub-backend/src/main/resources/db/migrations/001-schema.sql +++ b/legalconsenthub-backend/src/main/resources/db/migrations/001-schema.sql @@ -82,7 +82,7 @@ create table form_element form_element_order integer, is_clonable boolean not null, 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, id uuid not null, description varchar(255), @@ -152,6 +152,23 @@ create table table_column_mappings 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 ( form_element_id uuid not null, @@ -233,6 +250,16 @@ alter table if exists table_column_mappings foreign key (form_element_id) 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 add constraint FK5xuf7bd179ogpq5a1m3g8q7jb foreign key (form_element_id) diff --git a/legalconsenthub-backend/src/main/resources/seed/initial_application_form_template.yaml b/legalconsenthub-backend/src/main/resources/seed/initial_application_form_template.yaml index 3fbd3c2..21304a2 100644 --- a/legalconsenthub-backend/src/main/resources/seed/initial_application_form_template.yaml +++ b/legalconsenthub-backend/src/main/resources/seed/initial_application_form_template.yaml @@ -4839,3 +4839,14 @@ formElementSections: label: Nein processingPurpose: DATA_ANALYSIS 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: [] diff --git a/legalconsenthub/CLAUDE.md b/legalconsenthub/CLAUDE.md index 69325c8..27cb351 100644 --- a/legalconsenthub/CLAUDE.md +++ b/legalconsenthub/CLAUDE.md @@ -74,6 +74,7 @@ pnpm run api:generate - Use TypeScript interfaces for props and emits - Follow Vue 3 Composition API patterns - 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 diff --git a/legalconsenthub/app/components/FormEngine.vue b/legalconsenthub/app/components/FormEngine.vue index d0b6265..76ce7c2 100644 --- a/legalconsenthub/app/components/FormEngine.vue +++ b/legalconsenthub/app/components/FormEngine.vue @@ -15,6 +15,8 @@ :disabled="props.disabled" :all-form-elements="props.allFormElements" :table-row-preset="formElementItem.formElement.tableRowPreset" + :application-form-id="props.applicationFormId" + :form-element-reference="formElementItem.formElement.reference" @update:form-options="updateFormOptions($event, formElementItem)" />
@@ -171,6 +173,8 @@ function getResolvedComponent(formElement: FormElementDto) { return resolveComponent('TheDate') case 'TABLE': return resolveComponent('TheTable') + case 'FILE_UPLOAD': + return resolveComponent('TheFileUpload') default: return resolveComponent('Unimplemented') } diff --git a/legalconsenthub/app/components/formelements/TheFileUpload.vue b/legalconsenthub/app/components/formelements/TheFileUpload.vue new file mode 100644 index 0000000..3c572d8 --- /dev/null +++ b/legalconsenthub/app/components/formelements/TheFileUpload.vue @@ -0,0 +1,234 @@ + + + diff --git a/legalconsenthub/app/composables/applicationForm/useApplicationForm.ts b/legalconsenthub/app/composables/applicationForm/useApplicationForm.ts index 7a00010..0d74688 100644 --- a/legalconsenthub/app/composables/applicationForm/useApplicationForm.ts +++ b/legalconsenthub/app/composables/applicationForm/useApplicationForm.ts @@ -6,9 +6,56 @@ export function useApplicationForm() { const applicationFormApi = useApplicationFormApi() 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 { 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) { logger.error('Failed creating application form:', e) return Promise.reject(e) diff --git a/legalconsenthub/app/composables/file/useFile.ts b/legalconsenthub/app/composables/file/useFile.ts new file mode 100644 index 0000000..0cc6b03 --- /dev/null +++ b/legalconsenthub/app/composables/file/useFile.ts @@ -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 { + 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 { + 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 { + 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 { + 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 + } +} diff --git a/legalconsenthub/app/composables/file/useFileApi.ts b/legalconsenthub/app/composables/file/useFileApi.ts new file mode 100644 index 0000000..1f3c8dd --- /dev/null +++ b/legalconsenthub/app/composables/file/useFileApi.ts @@ -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 { + return fileApiClient.uploadFile(params) + } + + async function downloadFileContent(id: string, inline = false): Promise { + return fileApiClient.downloadFileContent({ id, inline }) + } + + function getFileViewUrl(id: string): string { + return `${basePath}/files/${id}/content?inline=true` + } + + async function deleteFile(id: string): Promise { + return fileApiClient.deleteFile({ id }) + } + + async function getFilesByApplicationForm( + applicationFormId: string, + formElementReference?: string + ): Promise { + return fileApiClient.getFilesByApplicationForm({ applicationFormId, formElementReference }) + } + + async function associateFilesWithApplicationForm(applicationFormId: string, fileIds: string[]): Promise { + const associateFilesWithApplicationFormRequest: AssociateFilesWithApplicationFormRequest = { fileIds } + return fileApiClient.associateFilesWithApplicationForm({ + applicationFormId, + associateFilesWithApplicationFormRequest + }) + } + + return { + uploadFile, + downloadFileContent, + getFileViewUrl, + deleteFile, + getFilesByApplicationForm, + associateFilesWithApplicationForm + } +} diff --git a/legalconsenthub/i18n/locales/de.json b/legalconsenthub/i18n/locales/de.json index fd57caa..1fce54e 100644 --- a/legalconsenthub/i18n/locales/de.json +++ b/legalconsenthub/i18n/locales/de.json @@ -32,6 +32,23 @@ "selectValue": "Wert auswählen", "enlargeTable": "Tabelle vergrößern", "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": { @@ -190,6 +207,7 @@ "save": "Speichern", "cancel": "Abbrechen", "delete": "Löschen", + "confirmDelete": "Möchten Sie dies wirklich löschen?", "edit": "Bearbeiten", "close": "Schließen", "confirm": "Bestätigen", diff --git a/legalconsenthub/i18n/locales/en.json b/legalconsenthub/i18n/locales/en.json index b52e009..3e8a439 100644 --- a/legalconsenthub/i18n/locales/en.json +++ b/legalconsenthub/i18n/locales/en.json @@ -32,6 +32,23 @@ "selectValue": "Select value", "enlargeTable": "Enlarge 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": { @@ -190,6 +207,7 @@ "save": "Save", "cancel": "Cancel", "delete": "Delete", + "confirmDelete": "Do you really want to delete this?", "edit": "Edit", "close": "Close", "confirm": "Confirm",