feat(fullstack): Add file upload
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.betriebsratkanzlei.legalconsenthub.error
|
||||
|
||||
class FileStorageException(
|
||||
message: String,
|
||||
cause: Throwable? = null,
|
||||
) : RuntimeException(message, cause)
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.betriebsratkanzlei.legalconsenthub.error
|
||||
|
||||
class FileTooLargeException(
|
||||
message: String,
|
||||
) : RuntimeException(message)
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.betriebsratkanzlei.legalconsenthub.error
|
||||
|
||||
class UnsupportedFileTypeException(
|
||||
message: String,
|
||||
) : RuntimeException(message)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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) },
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
)
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: []
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)"
|
||||
/>
|
||||
<div v-if="formElementItem.formElement.isClonable && !props.disabled" class="mt-3">
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
234
legalconsenthub/app/components/formelements/TheFileUpload.vue
Normal file
234
legalconsenthub/app/components/formelements/TheFileUpload.vue
Normal 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>
|
||||
@@ -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<ApplicationFormDto> {
|
||||
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)
|
||||
|
||||
150
legalconsenthub/app/composables/file/useFile.ts
Normal file
150
legalconsenthub/app/composables/file/useFile.ts
Normal 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
|
||||
}
|
||||
}
|
||||
64
legalconsenthub/app/composables/file/useFileApi.ts
Normal file
64
legalconsenthub/app/composables/file/useFileApi.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user