feat(fullstack): Add file upload
This commit is contained in:
@@ -1043,18 +1043,8 @@ paths:
|
|||||||
|
|
||||||
####### Files #######
|
####### Files #######
|
||||||
/files:
|
/files:
|
||||||
get:
|
|
||||||
summary: Get all files
|
|
||||||
operationId: getAllFiles
|
|
||||||
tags:
|
|
||||||
- file
|
|
||||||
responses:
|
|
||||||
"200":
|
|
||||||
description: Successful response
|
|
||||||
"500":
|
|
||||||
description: Internal server error
|
|
||||||
post:
|
post:
|
||||||
summary: Upload a new file
|
summary: Upload a new file for a form element
|
||||||
operationId: uploadFile
|
operationId: uploadFile
|
||||||
tags:
|
tags:
|
||||||
- file
|
- file
|
||||||
@@ -1063,18 +1053,24 @@ paths:
|
|||||||
content:
|
content:
|
||||||
multipart/form-data:
|
multipart/form-data:
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/UploadFileDto"
|
$ref: "#/components/schemas/UploadFileRequestDto"
|
||||||
responses:
|
responses:
|
||||||
"201":
|
"201":
|
||||||
description: File uploaded
|
description: File uploaded successfully
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/UploadedFileDto"
|
||||||
"400":
|
"400":
|
||||||
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/BadRequest"
|
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/BadRequest"
|
||||||
"401":
|
"401":
|
||||||
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/Unauthorized"
|
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/Unauthorized"
|
||||||
|
"413":
|
||||||
|
description: File too large (exceeds 10MB limit)
|
||||||
|
"415":
|
||||||
|
description: Unsupported file type
|
||||||
"500":
|
"500":
|
||||||
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServerError"
|
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServerError"
|
||||||
"503":
|
|
||||||
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServiceUnavailable"
|
|
||||||
|
|
||||||
/files/{id}:
|
/files/{id}:
|
||||||
parameters:
|
parameters:
|
||||||
@@ -1085,25 +1081,23 @@ paths:
|
|||||||
type: string
|
type: string
|
||||||
format: uuid
|
format: uuid
|
||||||
get:
|
get:
|
||||||
summary: Get a specific file
|
summary: Get file metadata by ID
|
||||||
operationId: getFileById
|
operationId: getFileById
|
||||||
tags:
|
tags:
|
||||||
- file
|
- file
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: Get file by ID
|
description: File metadata
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/FileDto"
|
$ref: "#/components/schemas/UploadedFileDto"
|
||||||
"400":
|
"404":
|
||||||
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/BadRequest"
|
description: File not found
|
||||||
"401":
|
"401":
|
||||||
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/Unauthorized"
|
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/Unauthorized"
|
||||||
"500":
|
"500":
|
||||||
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServerError"
|
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServerError"
|
||||||
"503":
|
|
||||||
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServiceUnavailable"
|
|
||||||
delete:
|
delete:
|
||||||
summary: Delete a file
|
summary: Delete a file
|
||||||
operationId: deleteFile
|
operationId: deleteFile
|
||||||
@@ -1116,10 +1110,123 @@ paths:
|
|||||||
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/BadRequest"
|
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/BadRequest"
|
||||||
"401":
|
"401":
|
||||||
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/Unauthorized"
|
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/Unauthorized"
|
||||||
|
"404":
|
||||||
|
description: File not found
|
||||||
|
"500":
|
||||||
|
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServerError"
|
||||||
|
|
||||||
|
/files/{id}/content:
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
get:
|
||||||
|
summary: Download file content
|
||||||
|
operationId: downloadFileContent
|
||||||
|
tags:
|
||||||
|
- file
|
||||||
|
parameters:
|
||||||
|
- name: inline
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
description: If true, return file with inline disposition for browser viewing
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: File binary content
|
||||||
|
content:
|
||||||
|
application/octet-stream:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: binary
|
||||||
|
headers:
|
||||||
|
Content-Disposition:
|
||||||
|
description: Attachment filename
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
Content-Type:
|
||||||
|
description: File MIME type
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
"404":
|
||||||
|
description: File not found
|
||||||
|
"401":
|
||||||
|
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/Unauthorized"
|
||||||
|
"500":
|
||||||
|
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServerError"
|
||||||
|
|
||||||
|
/application-forms/{applicationFormId}/files:
|
||||||
|
parameters:
|
||||||
|
- name: applicationFormId
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: The application form ID
|
||||||
|
get:
|
||||||
|
summary: Get all files for an application form
|
||||||
|
operationId: getFilesByApplicationForm
|
||||||
|
tags:
|
||||||
|
- file
|
||||||
|
parameters:
|
||||||
|
- name: formElementReference
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: Filter by form element reference key
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: List of files for the application form
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/UploadedFileDto"
|
||||||
|
"401":
|
||||||
|
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/Unauthorized"
|
||||||
|
"404":
|
||||||
|
description: Application form not found
|
||||||
|
"500":
|
||||||
|
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServerError"
|
||||||
|
post:
|
||||||
|
summary: Associate files with an application form
|
||||||
|
operationId: associateFilesWithApplicationForm
|
||||||
|
tags:
|
||||||
|
- file
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- fileIds
|
||||||
|
properties:
|
||||||
|
fileIds:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: List of file IDs to associate with this application form
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: Files successfully associated
|
||||||
|
"400":
|
||||||
|
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/BadRequest"
|
||||||
|
"401":
|
||||||
|
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/Unauthorized"
|
||||||
|
"404":
|
||||||
|
description: Application form or file not found
|
||||||
"500":
|
"500":
|
||||||
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServerError"
|
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServerError"
|
||||||
"503":
|
|
||||||
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServiceUnavailable"
|
|
||||||
|
|
||||||
components:
|
components:
|
||||||
securitySchemes:
|
securitySchemes:
|
||||||
@@ -1177,6 +1284,14 @@ components:
|
|||||||
nullable: true
|
nullable: true
|
||||||
readOnly: true
|
readOnly: true
|
||||||
description: Total number of comments associated with this application form.
|
description: Total number of comments associated with this application form.
|
||||||
|
fileIds:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
nullable: true
|
||||||
|
writeOnly: true
|
||||||
|
description: Temporary file IDs to associate atomically on creation (write-only, ignored on read)
|
||||||
|
|
||||||
PagedApplicationFormDto:
|
PagedApplicationFormDto:
|
||||||
type: object
|
type: object
|
||||||
@@ -1574,6 +1689,7 @@ components:
|
|||||||
- RICH_TEXT
|
- RICH_TEXT
|
||||||
- DATE
|
- DATE
|
||||||
- TABLE
|
- TABLE
|
||||||
|
- FILE_UPLOAD
|
||||||
|
|
||||||
FormElementVisibilityCondition:
|
FormElementVisibilityCondition:
|
||||||
type: object
|
type: object
|
||||||
@@ -1880,41 +1996,79 @@ components:
|
|||||||
- WARNING
|
- WARNING
|
||||||
- ERROR
|
- ERROR
|
||||||
|
|
||||||
####### FileDto #######
|
####### File Upload DTOs #######
|
||||||
FileDto:
|
UploadedFileDto:
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
- id
|
- id
|
||||||
- name
|
- filename
|
||||||
- file
|
- originalFilename
|
||||||
- createdAt
|
- size
|
||||||
- modifiedAt
|
- mimeType
|
||||||
|
- formElementReference
|
||||||
|
- uploadedAt
|
||||||
properties:
|
properties:
|
||||||
id:
|
id:
|
||||||
type: string
|
type: string
|
||||||
format: uuid
|
format: uuid
|
||||||
name:
|
description: Unique identifier for the uploaded file
|
||||||
|
filename:
|
||||||
type: string
|
type: string
|
||||||
file:
|
description: Unique filename stored on disk (UUID-based)
|
||||||
|
originalFilename:
|
||||||
type: string
|
type: string
|
||||||
createdAt:
|
description: Original filename provided by the user
|
||||||
type: string
|
size:
|
||||||
format: date-time
|
type: integer
|
||||||
modifiedAt:
|
format: int64
|
||||||
|
description: File size in bytes
|
||||||
|
mimeType:
|
||||||
|
type: string
|
||||||
|
description: MIME type (e.g., application/pdf, image/jpeg)
|
||||||
|
organizationId:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
description: Organization context (null for global forms)
|
||||||
|
applicationFormId:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
nullable: true
|
||||||
|
description: The application form this file belongs to (null for temporary uploads)
|
||||||
|
formElementReference:
|
||||||
|
type: string
|
||||||
|
description: Reference key of the form element (e.g., grundrechte_folgenabschaetzung)
|
||||||
|
uploadedAt:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
|
description: Timestamp when the file was uploaded
|
||||||
|
uploadedBy:
|
||||||
|
nullable: true
|
||||||
|
allOf:
|
||||||
|
- $ref: "#/components/schemas/UserDto"
|
||||||
|
description: User who uploaded the file
|
||||||
|
|
||||||
UploadFileDto:
|
UploadFileRequestDto:
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
- name
|
|
||||||
- file
|
- file
|
||||||
|
- formElementReference
|
||||||
properties:
|
properties:
|
||||||
name:
|
|
||||||
type: string
|
|
||||||
file:
|
file:
|
||||||
type: string
|
type: string
|
||||||
format: binary
|
format: binary
|
||||||
|
description: The file to upload
|
||||||
|
organizationId:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
description: Organization context (null for global forms)
|
||||||
|
applicationFormId:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
nullable: true
|
||||||
|
description: The application form this file belongs to (null for temporary uploads before form is saved)
|
||||||
|
formElementReference:
|
||||||
|
type: string
|
||||||
|
description: Reference key of the form element
|
||||||
|
|
||||||
####### Miscellaneous #######
|
####### Miscellaneous #######
|
||||||
ProcessingPurpose:
|
ProcessingPurpose:
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ dependencies {
|
|||||||
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
|
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-actuator'
|
implementation 'org.springframework.boot:spring-boot-starter-actuator'
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-mail'
|
implementation 'org.springframework.boot:spring-boot-starter-mail'
|
||||||
|
implementation 'org.apache.tika:tika-core:2.9.1'
|
||||||
runtimeOnly 'com.h2database:h2'
|
runtimeOnly 'com.h2database:h2'
|
||||||
implementation 'org.postgresql:postgresql'
|
implementation 'org.postgresql:postgresql'
|
||||||
implementation 'org.springframework.boot:spring-boot-testcontainers'
|
implementation 'org.springframework.boot:spring-boot-testcontainers'
|
||||||
|
|||||||
@@ -133,6 +133,27 @@ class ApplicationFormFormatService(
|
|||||||
"SWITCH" -> {
|
"SWITCH" -> {
|
||||||
if (element.options.any { it.value == "true" }) "Ja" else "Nein"
|
if (element.options.any { it.value == "true" }) "Ja" else "Nein"
|
||||||
}
|
}
|
||||||
|
"FILE_UPLOAD" -> {
|
||||||
|
val files =
|
||||||
|
element.options.mapNotNull { option ->
|
||||||
|
val value = option.value
|
||||||
|
if (value.isBlank()) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
val metadata = jacksonObjectMapper().readValue(value, Map::class.java)
|
||||||
|
metadata["filename"] as? String
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (files.isEmpty()) {
|
||||||
|
"Keine Dateien hochgeladen"
|
||||||
|
} else {
|
||||||
|
files.joinToString("\\\\") { LatexEscaper.escape(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
"TABLE" -> {
|
"TABLE" -> {
|
||||||
renderTableValue(element).first
|
renderTableValue(element).first
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotCreatedExc
|
|||||||
import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotDeletedException
|
import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotDeletedException
|
||||||
import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotFoundException
|
import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotFoundException
|
||||||
import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotUpdatedException
|
import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotUpdatedException
|
||||||
|
import com.betriebsratkanzlei.legalconsenthub.file.FileService
|
||||||
import com.betriebsratkanzlei.legalconsenthub.form_element.FormElementMapper
|
import com.betriebsratkanzlei.legalconsenthub.form_element.FormElementMapper
|
||||||
import com.betriebsratkanzlei.legalconsenthub.notification.NotificationService
|
import com.betriebsratkanzlei.legalconsenthub.notification.NotificationService
|
||||||
import com.betriebsratkanzlei.legalconsenthub.user.UserService
|
import com.betriebsratkanzlei.legalconsenthub.user.UserService
|
||||||
@@ -22,6 +23,7 @@ import org.springframework.context.ApplicationEventPublisher
|
|||||||
import org.springframework.data.domain.Page
|
import org.springframework.data.domain.Page
|
||||||
import org.springframework.data.domain.PageRequest
|
import org.springframework.data.domain.PageRequest
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
data class ApplicationFormPageWithCommentCounts(
|
data class ApplicationFormPageWithCommentCounts(
|
||||||
@@ -39,7 +41,9 @@ class ApplicationFormService(
|
|||||||
private val userService: UserService,
|
private val userService: UserService,
|
||||||
private val eventPublisher: ApplicationEventPublisher,
|
private val eventPublisher: ApplicationEventPublisher,
|
||||||
private val commentRepository: CommentRepository,
|
private val commentRepository: CommentRepository,
|
||||||
|
private val fileService: FileService,
|
||||||
) {
|
) {
|
||||||
|
@Transactional(rollbackFor = [Exception::class])
|
||||||
fun createApplicationForm(applicationFormDto: ApplicationFormDto): ApplicationForm {
|
fun createApplicationForm(applicationFormDto: ApplicationFormDto): ApplicationForm {
|
||||||
val applicationForm = applicationFormMapper.toNewApplicationForm(applicationFormDto)
|
val applicationForm = applicationFormMapper.toNewApplicationForm(applicationFormDto)
|
||||||
val savedApplicationForm: ApplicationForm
|
val savedApplicationForm: ApplicationForm
|
||||||
@@ -49,6 +53,15 @@ class ApplicationFormService(
|
|||||||
throw ApplicationFormNotCreatedException(e)
|
throw ApplicationFormNotCreatedException(e)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Associate files atomically if provided
|
||||||
|
val fileIds = applicationFormDto.fileIds
|
||||||
|
if (!fileIds.isNullOrEmpty()) {
|
||||||
|
fileService.associateTemporaryFilesTransactional(
|
||||||
|
fileIds,
|
||||||
|
savedApplicationForm,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
val currentUser = userService.getCurrentUser()
|
val currentUser = userService.getCurrentUser()
|
||||||
versionService.createVersion(savedApplicationForm, currentUser)
|
versionService.createVersion(savedApplicationForm, currentUser)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
timeout: 5000
|
||||||
writetimeout: 5000
|
writetimeout: 5000
|
||||||
|
|
||||||
|
servlet:
|
||||||
|
multipart:
|
||||||
|
max-file-size: 10MB
|
||||||
|
max-request-size: 50MB
|
||||||
|
|
||||||
|
legalconsenthub:
|
||||||
|
file:
|
||||||
|
storage:
|
||||||
|
filesystem:
|
||||||
|
base-dir: ${LEGALCONSENTHUB_FILE_STORAGE_FILESYSTEM_BASE_DIR:./data/files}
|
||||||
|
|
||||||
management:
|
management:
|
||||||
health:
|
health:
|
||||||
mail:
|
mail:
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ create table form_element
|
|||||||
form_element_order integer,
|
form_element_order integer,
|
||||||
is_clonable boolean not null,
|
is_clonable boolean not null,
|
||||||
row_preset_filter_src_col_idx integer,
|
row_preset_filter_src_col_idx integer,
|
||||||
type smallint not null check (type between 0 and 8),
|
type smallint not null check (type between 0 and 9),
|
||||||
form_element_sub_section_id uuid not null,
|
form_element_sub_section_id uuid not null,
|
||||||
id uuid not null,
|
id uuid not null,
|
||||||
description varchar(255),
|
description varchar(255),
|
||||||
@@ -152,6 +152,23 @@ create table table_column_mappings
|
|||||||
form_element_id uuid not null
|
form_element_id uuid not null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
create table uploaded_file
|
||||||
|
(
|
||||||
|
is_temporary boolean not null,
|
||||||
|
size bigint not null,
|
||||||
|
uploaded_at timestamp(6) with time zone not null,
|
||||||
|
application_form_id uuid,
|
||||||
|
id uuid not null,
|
||||||
|
filename varchar(255) not null,
|
||||||
|
form_element_reference varchar(255) not null,
|
||||||
|
mime_type varchar(255) not null,
|
||||||
|
organization_id varchar(255),
|
||||||
|
original_filename varchar(255) not null,
|
||||||
|
storage_path varchar(255) not null,
|
||||||
|
uploaded_by_id varchar(255),
|
||||||
|
primary key (id)
|
||||||
|
);
|
||||||
|
|
||||||
create table visibility_conditions
|
create table visibility_conditions
|
||||||
(
|
(
|
||||||
form_element_id uuid not null,
|
form_element_id uuid not null,
|
||||||
@@ -233,6 +250,16 @@ alter table if exists table_column_mappings
|
|||||||
foreign key (form_element_id)
|
foreign key (form_element_id)
|
||||||
references form_element;
|
references form_element;
|
||||||
|
|
||||||
|
alter table if exists uploaded_file
|
||||||
|
add constraint FKn866ru0c9ygi5wsqvliv181uj
|
||||||
|
foreign key (application_form_id)
|
||||||
|
references application_form;
|
||||||
|
|
||||||
|
alter table if exists uploaded_file
|
||||||
|
add constraint FKtg323a9339lx0do79gu4eftao
|
||||||
|
foreign key (uploaded_by_id)
|
||||||
|
references app_user;
|
||||||
|
|
||||||
alter table if exists visibility_conditions
|
alter table if exists visibility_conditions
|
||||||
add constraint FK5xuf7bd179ogpq5a1m3g8q7jb
|
add constraint FK5xuf7bd179ogpq5a1m3g8q7jb
|
||||||
foreign key (form_element_id)
|
foreign key (form_element_id)
|
||||||
|
|||||||
@@ -4839,3 +4839,14 @@ formElementSections:
|
|||||||
label: Nein
|
label: Nein
|
||||||
processingPurpose: DATA_ANALYSIS
|
processingPurpose: DATA_ANALYSIS
|
||||||
employeeDataCategory: SENSITIVE
|
employeeDataCategory: SENSITIVE
|
||||||
|
|
||||||
|
- reference: ki_dokumentation
|
||||||
|
title: Dokumentation zur Grundrechte-Folgenabschätzung
|
||||||
|
description: Bitte laden Sie die Dokumentation zur Grundrechte-Folgenabschätzung hoch
|
||||||
|
type: FILE_UPLOAD
|
||||||
|
visibilityConditions:
|
||||||
|
- formElementConditionType: SHOW
|
||||||
|
sourceFormElementReference: ki_info_grundrechte_folgenabschaetzung
|
||||||
|
formElementExpectedValue: Ja (bitte beilegen)
|
||||||
|
formElementOperator: EQUALS
|
||||||
|
options: []
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ pnpm run api:generate
|
|||||||
- Use TypeScript interfaces for props and emits
|
- Use TypeScript interfaces for props and emits
|
||||||
- Follow Vue 3 Composition API patterns
|
- Follow Vue 3 Composition API patterns
|
||||||
- Use Nuxt UI components for consistent design
|
- Use Nuxt UI components for consistent design
|
||||||
|
- API calls are wrapped in composables or Pinia actions. The `/composables/applicationFormTemplate` are a good reference.
|
||||||
|
|
||||||
### i18n Best Practices
|
### i18n Best Practices
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
:disabled="props.disabled"
|
:disabled="props.disabled"
|
||||||
:all-form-elements="props.allFormElements"
|
:all-form-elements="props.allFormElements"
|
||||||
:table-row-preset="formElementItem.formElement.tableRowPreset"
|
:table-row-preset="formElementItem.formElement.tableRowPreset"
|
||||||
|
:application-form-id="props.applicationFormId"
|
||||||
|
:form-element-reference="formElementItem.formElement.reference"
|
||||||
@update:form-options="updateFormOptions($event, formElementItem)"
|
@update:form-options="updateFormOptions($event, formElementItem)"
|
||||||
/>
|
/>
|
||||||
<div v-if="formElementItem.formElement.isClonable && !props.disabled" class="mt-3">
|
<div v-if="formElementItem.formElement.isClonable && !props.disabled" class="mt-3">
|
||||||
@@ -171,6 +173,8 @@ function getResolvedComponent(formElement: FormElementDto) {
|
|||||||
return resolveComponent('TheDate')
|
return resolveComponent('TheDate')
|
||||||
case 'TABLE':
|
case 'TABLE':
|
||||||
return resolveComponent('TheTable')
|
return resolveComponent('TheTable')
|
||||||
|
case 'FILE_UPLOAD':
|
||||||
|
return resolveComponent('TheFileUpload')
|
||||||
default:
|
default:
|
||||||
return resolveComponent('Unimplemented')
|
return resolveComponent('Unimplemented')
|
||||||
}
|
}
|
||||||
|
|||||||
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 applicationFormApi = useApplicationFormApi()
|
||||||
const logger = useLogger().withTag('applicationForm')
|
const logger = useLogger().withTag('applicationForm')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract all file IDs from FILE_UPLOAD form elements
|
||||||
|
*/
|
||||||
|
function extractFileIdsFromForm(applicationFormDto: ApplicationFormDto): string[] {
|
||||||
|
const fileIds: string[] = []
|
||||||
|
|
||||||
|
applicationFormDto.formElementSections?.forEach((section) => {
|
||||||
|
section.formElementSubSections?.forEach((subsection) => {
|
||||||
|
subsection.formElements?.forEach((element) => {
|
||||||
|
if (element.type === 'FILE_UPLOAD') {
|
||||||
|
element.options?.forEach((option) => {
|
||||||
|
try {
|
||||||
|
const metadata = JSON.parse(option.value)
|
||||||
|
if (metadata.fileId) {
|
||||||
|
fileIds.push(metadata.fileId)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore parsing errors
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return fileIds
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an application form with atomic file association.
|
||||||
|
*
|
||||||
|
* File IDs are included in the DTO and associated atomically on the backend.
|
||||||
|
* If file association fails, the entire operation rolls back (form is not created).
|
||||||
|
*/
|
||||||
async function createApplicationForm(applicationFormDto: ApplicationFormDto): Promise<ApplicationFormDto> {
|
async function createApplicationForm(applicationFormDto: ApplicationFormDto): Promise<ApplicationFormDto> {
|
||||||
try {
|
try {
|
||||||
return await applicationFormApi.createApplicationForm(applicationFormDto)
|
// Extract all temporary file IDs and include them in the DTO for atomic association
|
||||||
|
const fileIds = extractFileIdsFromForm(applicationFormDto)
|
||||||
|
|
||||||
|
// Single atomic API call - backend handles form creation and file association transactionally
|
||||||
|
const createdForm = await applicationFormApi.createApplicationForm({
|
||||||
|
...applicationFormDto,
|
||||||
|
fileIds: fileIds.length > 0 ? fileIds : undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
if (fileIds.length > 0) {
|
||||||
|
logger.debug(`Created form ${createdForm.id} with ${fileIds.length} files atomically associated`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return createdForm
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
logger.error('Failed creating application form:', e)
|
logger.error('Failed creating application form:', e)
|
||||||
return Promise.reject(e)
|
return Promise.reject(e)
|
||||||
|
|||||||
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",
|
"selectValue": "Wert auswählen",
|
||||||
"enlargeTable": "Tabelle vergrößern",
|
"enlargeTable": "Tabelle vergrößern",
|
||||||
"editTable": "Tabelle bearbeiten"
|
"editTable": "Tabelle bearbeiten"
|
||||||
|
},
|
||||||
|
"fileUpload": {
|
||||||
|
"label": "Dateien hochladen",
|
||||||
|
"dropFiles": "Dateien hier ablegen",
|
||||||
|
"orClickToUpload": "oder klicken Sie, um Dateien auszuwählen",
|
||||||
|
"selectFiles": "Dateien auswählen",
|
||||||
|
"allowedTypes": "Erlaubt: PDF, DOCX, DOC, ODT, JPG, PNG, ZIP (max. 10MB pro Datei)",
|
||||||
|
"uploadedFiles": "Hochgeladene Dateien",
|
||||||
|
"uploading": "Wird hochgeladen...",
|
||||||
|
"uploadError": "Upload-Fehler",
|
||||||
|
"uploadFailed": "Datei-Upload fehlgeschlagen. Bitte versuchen Sie es erneut.",
|
||||||
|
"fileTooLarge": "Die Datei \"{filename}\" ist zu groß. Maximale Größe: {maxSize}",
|
||||||
|
"unsupportedType": "Dieser Dateityp wird nicht unterstützt.",
|
||||||
|
"deleteFailed": "Datei konnte nicht gelöscht werden.",
|
||||||
|
"downloadFailed": "Datei konnte nicht heruntergeladen werden.",
|
||||||
|
"viewFailed": "Datei konnte nicht zur Ansicht geöffnet werden.",
|
||||||
|
"view": "Ansehen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
@@ -190,6 +207,7 @@
|
|||||||
"save": "Speichern",
|
"save": "Speichern",
|
||||||
"cancel": "Abbrechen",
|
"cancel": "Abbrechen",
|
||||||
"delete": "Löschen",
|
"delete": "Löschen",
|
||||||
|
"confirmDelete": "Möchten Sie dies wirklich löschen?",
|
||||||
"edit": "Bearbeiten",
|
"edit": "Bearbeiten",
|
||||||
"close": "Schließen",
|
"close": "Schließen",
|
||||||
"confirm": "Bestätigen",
|
"confirm": "Bestätigen",
|
||||||
|
|||||||
@@ -32,6 +32,23 @@
|
|||||||
"selectValue": "Select value",
|
"selectValue": "Select value",
|
||||||
"enlargeTable": "Enlarge table",
|
"enlargeTable": "Enlarge table",
|
||||||
"editTable": "Edit table"
|
"editTable": "Edit table"
|
||||||
|
},
|
||||||
|
"fileUpload": {
|
||||||
|
"label": "Upload files",
|
||||||
|
"dropFiles": "Drop files here",
|
||||||
|
"orClickToUpload": "or click to select files",
|
||||||
|
"selectFiles": "Select files",
|
||||||
|
"allowedTypes": "Allowed: PDF, DOCX, DOC, ODT, JPG, PNG, ZIP (max. 10MB per file)",
|
||||||
|
"uploadedFiles": "Uploaded files",
|
||||||
|
"uploading": "Uploading...",
|
||||||
|
"uploadError": "Upload error",
|
||||||
|
"uploadFailed": "File upload failed. Please try again.",
|
||||||
|
"fileTooLarge": "The file \"{filename}\" is too large. Maximum size: {maxSize}",
|
||||||
|
"unsupportedType": "This file type is not supported.",
|
||||||
|
"deleteFailed": "Could not delete file.",
|
||||||
|
"downloadFailed": "Could not download file.",
|
||||||
|
"viewFailed": "Could not open file for viewing.",
|
||||||
|
"view": "View"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
@@ -190,6 +207,7 @@
|
|||||||
"save": "Save",
|
"save": "Save",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
|
"confirmDelete": "Do you really want to delete this?",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
"confirm": "Confirm",
|
"confirm": "Confirm",
|
||||||
|
|||||||
Reference in New Issue
Block a user