diff --git a/application/src/jvmMain/kotlin/replace/dto/FileDto.kt b/application/src/jvmMain/kotlin/replace/dto/FileDto.kt new file mode 100644 index 00000000..93d581da --- /dev/null +++ b/application/src/jvmMain/kotlin/replace/dto/FileDto.kt @@ -0,0 +1,31 @@ +package replace.dto + +import kotlinx.serialization.Serializable +import replace.model.File + +@Serializable +class FileDto( + override val id: String? = null, + val name: String, + val path: String, + val extension: String, + val sizeInBytes: Long, + val mime: String? = null, +) : Dto + +fun File.toDto() = FileDto( + id = id?.toHexString(), + name = name, + path = path, + extension = extension, + sizeInBytes = sizeInBytes, + mime = mime, +) + +fun FileDto.toModel() = File( + name = name, + path = path, + extension = extension, + sizeInBytes = sizeInBytes, + mime = mime, +) diff --git a/application/src/jvmMain/kotlin/replace/dto/FileUploadDto.kt b/application/src/jvmMain/kotlin/replace/dto/FileUploadDto.kt new file mode 100644 index 00000000..ca9607a6 --- /dev/null +++ b/application/src/jvmMain/kotlin/replace/dto/FileUploadDto.kt @@ -0,0 +1,37 @@ +package replace.dto + +import kotlinx.serialization.Serializable +import replace.datastore.FileRepository +import replace.datastore.FileStorage +import replace.datastore.TemporaryFileRepository +import replace.usecase.temporaryfileupload.SaveTemporaryFileUploadPersistentUseCase + +@Serializable +class FileUploadDto( + override val id: String, + val temporary: Boolean, +) : Dto + +suspend fun FileUploadDto.save( + temporaryFileRepository: TemporaryFileRepository, + fileRepository: FileRepository, + fileStorage: FileStorage, +): FileUploadDto { + if (!temporary) { + return this + } + + val file = SaveTemporaryFileUploadPersistentUseCase.execute( + id, + temporaryFileRepository, + fileRepository, + fileStorage, + ) + + checkNotNull(file.id) { "Could not save file" } + + return FileUploadDto( + id = file.id, + temporary = false, + ) +} diff --git a/application/src/jvmMain/kotlin/replace/dto/FloorDto.kt b/application/src/jvmMain/kotlin/replace/dto/FloorDto.kt index 7d51305c..2aa0baa4 100644 --- a/application/src/jvmMain/kotlin/replace/dto/FloorDto.kt +++ b/application/src/jvmMain/kotlin/replace/dto/FloorDto.kt @@ -2,6 +2,9 @@ package replace.dto import kotlinx.serialization.Serializable import org.bson.types.ObjectId +import replace.datastore.FileRepository +import replace.datastore.FileStorage +import replace.datastore.TemporaryFileRepository import replace.model.Floor @Serializable @@ -9,12 +12,50 @@ class FloorDto( override val id: String? = null, val name: String, val siteId: String, + val planFile: FileUploadDto? = null, ) : Dto -fun Floor.toDto() = FloorDto( - id = id?.toHexString(), - name = name, - siteId = siteId.toHexString(), -) +fun Floor.toDto(): FloorDto { -fun FloorDto.toModel() = Floor(name, ObjectId(siteId)) + val fileId = planFileId?.toHexString() + ?: return FloorDto( + id = id?.toHexString(), + name = name, + siteId = siteId.toHexString(), + ) + + return FloorDto( + id = id?.toHexString(), + name = name, + siteId = siteId.toHexString(), + planFile = FileUploadDto( + id = fileId, + temporary = false, + ), + ) +} + +fun FloorDto.toModel(): Floor { + return Floor( + name = name, + siteId = ObjectId(siteId), + planFileId = planFile?.let { ObjectId(planFile.id) }, + ) +} + +suspend fun FloorDto.saveFiles( + temporaryFileRepository: TemporaryFileRepository, + fileRepository: FileRepository, + fileStorage: FileStorage +): FloorDto { + return FloorDto( + id = id, + name = name, + siteId = siteId, + planFile = planFile?.save( + temporaryFileRepository = temporaryFileRepository, + fileRepository = fileRepository, + fileStorage = fileStorage, + ), + ) +} diff --git a/application/src/jvmMain/kotlin/replace/dto/TemporaryFileUploadDto.kt b/application/src/jvmMain/kotlin/replace/dto/TemporaryFileUploadDto.kt new file mode 100644 index 00000000..23079df4 --- /dev/null +++ b/application/src/jvmMain/kotlin/replace/dto/TemporaryFileUploadDto.kt @@ -0,0 +1,35 @@ +package replace.dto + +import kotlinx.serialization.Serializable +import replace.model.TemporaryFile +import java.time.LocalDateTime + +@Serializable +class TemporaryFileUploadDto( + override val id: String? = null, + val name: String, + val path: String, + val mime: String? = null, + val extension: String, + val sizeInBytes: Long, + val createdAt: String, +) : Dto + +fun TemporaryFile.toDto() = TemporaryFileUploadDto( + id = id?.toHexString(), + name = name, + path = path, + mime = mime, + extension = extension, + sizeInBytes = sizeInBytes, + createdAt = createdAt.toString() +) + +fun TemporaryFileUploadDto.toModel() = TemporaryFile( + name = name, + path = path, + extension = extension, + mime = mime, + sizeInBytes = sizeInBytes, + createdAt = LocalDateTime.parse(createdAt) +) diff --git a/application/src/jvmMain/kotlin/replace/usecase/file/CreateFileUseCase.kt b/application/src/jvmMain/kotlin/replace/usecase/file/CreateFileUseCase.kt new file mode 100644 index 00000000..8d898061 --- /dev/null +++ b/application/src/jvmMain/kotlin/replace/usecase/file/CreateFileUseCase.kt @@ -0,0 +1,47 @@ +package replace.usecase.file + +import org.bson.types.ObjectId +import replace.datastore.FileRepository +import replace.datastore.FileStorage +import replace.datastore.TemporaryFileRepository +import replace.dto.FileDto +import replace.dto.toDto +import replace.model.File +import java.util.UUID + +object CreateFileUseCase { + + suspend fun execute( + temporaryFileUploadId: String, + temporaryFileRepository: TemporaryFileRepository, + fileRepository: FileRepository, + fileStorage: FileStorage, + ): FileDto { + if (!ObjectId.isValid(temporaryFileUploadId)) { + throw IllegalArgumentException("Id $temporaryFileUploadId is not a valid ObjectId") + } + + val temporaryFileUpload = temporaryFileRepository.findOneById(ObjectId(temporaryFileUploadId)) + ?: throw IllegalArgumentException("Temporary file upload with id $temporaryFileUploadId not found") + + val newFilePath = "uploads/${UUID.randomUUID()}/base.${temporaryFileUpload.extension}" + + if (!fileStorage.copyFile(temporaryFileUpload.path, newFilePath)) { + throw IllegalStateException("Could not copy file from ${temporaryFileUpload.path} to $newFilePath") + } + + val file = File( + name = temporaryFileUpload.name, + path = newFilePath, + mime = temporaryFileUpload.mime, + extension = temporaryFileUpload.extension, + sizeInBytes = fileStorage.getFileSize(newFilePath), + ) + + val insertedFile = fileRepository.insertOne(file) + + checkNotNull(insertedFile) { "Could not insert File into Database" } + + return insertedFile.toDto() + } +} diff --git a/application/src/jvmMain/kotlin/replace/usecase/file/DeleteFileUseCase.kt b/application/src/jvmMain/kotlin/replace/usecase/file/DeleteFileUseCase.kt new file mode 100644 index 00000000..2f6fcf74 --- /dev/null +++ b/application/src/jvmMain/kotlin/replace/usecase/file/DeleteFileUseCase.kt @@ -0,0 +1,27 @@ +package replace.usecase.file + +import org.bson.types.ObjectId +import replace.datastore.FileRepository +import replace.datastore.FileStorage + +object DeleteFileUseCase { + + suspend fun execute( + fileId: String, + fileRepository: FileRepository, + fileStorage: FileStorage, + ) { + if (!ObjectId.isValid(fileId)) { + throw IllegalArgumentException("Id $fileId: is not a valid ObjectId") + } + + val file = fileRepository.findOneById(ObjectId(fileId)) + ?: throw IllegalArgumentException("File with id $fileId not found") + + if (fileStorage.deleteFile(file.path)) { + fileRepository.deleteOneById(ObjectId(fileId)) + } else { + throw IllegalStateException("Could not delete file with id $fileId") + } + } +} diff --git a/application/src/jvmMain/kotlin/replace/usecase/floor/CreateFloorUseCase.kt b/application/src/jvmMain/kotlin/replace/usecase/floor/CreateFloorUseCase.kt index 7afc325b..6c0ff916 100644 --- a/application/src/jvmMain/kotlin/replace/usecase/floor/CreateFloorUseCase.kt +++ b/application/src/jvmMain/kotlin/replace/usecase/floor/CreateFloorUseCase.kt @@ -1,8 +1,11 @@ package replace.usecase.floor import org.bson.types.ObjectId +import replace.datastore.FileRepository +import replace.datastore.FileStorage import replace.datastore.FloorRepository import replace.datastore.SiteRepository +import replace.datastore.TemporaryFileRepository import replace.dto.FloorDto import replace.dto.toDto import replace.dto.toModel @@ -12,12 +15,26 @@ object CreateFloorUseCase { floorDto: FloorDto, floorRepository: FloorRepository, siteRepository: SiteRepository, + temporaryFileRepository: TemporaryFileRepository, + fileRepository: FileRepository, + fileStorage: FileStorage, ): FloorDto { val siteId = ObjectId(floorDto.siteId) val site = siteRepository.findOneById(siteId) checkNotNull(site) { "Site with id $siteId not found" } - val insertedFloor = floorRepository.insertOne(floorDto.toModel()) + + val floorDtoWithPlan = SaveFloorPlanFileUseCase.execute( + floorDto, + floorRepository, + temporaryFileRepository, + fileRepository, + fileStorage, + ) + + val insertedFloor = floorRepository.insertOne(floorDtoWithPlan.toModel()) + checkNotNull(insertedFloor) { "Could not insert BookableEntity" } + return insertedFloor.toDto() } } diff --git a/application/src/jvmMain/kotlin/replace/usecase/floor/SaveFloorPlanFileUseCase.kt b/application/src/jvmMain/kotlin/replace/usecase/floor/SaveFloorPlanFileUseCase.kt new file mode 100644 index 00000000..15569bfe --- /dev/null +++ b/application/src/jvmMain/kotlin/replace/usecase/floor/SaveFloorPlanFileUseCase.kt @@ -0,0 +1,34 @@ +package replace.usecase.floor + +import org.bson.types.ObjectId +import replace.datastore.FileRepository +import replace.datastore.FileStorage +import replace.datastore.FloorRepository +import replace.datastore.TemporaryFileRepository +import replace.dto.FloorDto +import replace.dto.saveFiles +import replace.usecase.file.DeleteFileUseCase + +object SaveFloorPlanFileUseCase { + suspend fun execute( + floorDto: FloorDto, + floorRepository: FloorRepository, + temporaryFileRepository: TemporaryFileRepository, + fileRepository: FileRepository, + fileStorage: FileStorage, + ): FloorDto { + val oldPlanFileId = floorDto.id?.let { floorRepository.findOneById(ObjectId(it)) }?.planFileId?.toHexString() + + val saved = floorDto.saveFiles( + temporaryFileRepository = temporaryFileRepository, + fileRepository = fileRepository, + fileStorage = fileStorage, + ) + + if (oldPlanFileId != saved.planFile?.id) { + oldPlanFileId?.let { DeleteFileUseCase.execute(it, fileRepository, fileStorage) } + } + + return saved + } +} diff --git a/application/src/jvmMain/kotlin/replace/usecase/floor/UpdateFloorUseCase.kt b/application/src/jvmMain/kotlin/replace/usecase/floor/UpdateFloorUseCase.kt index ead5ed95..e0ce0ef8 100644 --- a/application/src/jvmMain/kotlin/replace/usecase/floor/UpdateFloorUseCase.kt +++ b/application/src/jvmMain/kotlin/replace/usecase/floor/UpdateFloorUseCase.kt @@ -1,7 +1,10 @@ package replace.usecase.floor import org.bson.types.ObjectId +import replace.datastore.FileRepository +import replace.datastore.FileStorage import replace.datastore.FloorRepository +import replace.datastore.TemporaryFileRepository import replace.dto.FloorDto import replace.dto.toDto import replace.dto.toModel @@ -10,12 +13,23 @@ object UpdateFloorUseCase { suspend fun execute( dto: FloorDto, repository: FloorRepository, + temporaryFileRepository: TemporaryFileRepository, + fileRepository: FileRepository, + fileStorage: FileStorage, ): FloorDto { val floorId = ObjectId(dto.id) - val updatedModel = repository.updateOne(floorId, dto.toModel()) + val floorDtoWithPlan = SaveFloorPlanFileUseCase.execute( + dto, + repository, + temporaryFileRepository, + fileRepository, + fileStorage, + ) - checkNotNull(updatedModel) { "Could not update Floor" } + val updatedModel = repository.updateOne(floorId, floorDtoWithPlan.toModel()) + + checkNotNull(updatedModel) { "Could not update Floor with id $floorId\n$floorDtoWithPlan" } return updatedModel.toDto() } diff --git a/application/src/jvmMain/kotlin/replace/usecase/temporaryfileupload/CreateTemporaryFileUploadUseCase.kt b/application/src/jvmMain/kotlin/replace/usecase/temporaryfileupload/CreateTemporaryFileUploadUseCase.kt new file mode 100644 index 00000000..cde9d765 --- /dev/null +++ b/application/src/jvmMain/kotlin/replace/usecase/temporaryfileupload/CreateTemporaryFileUploadUseCase.kt @@ -0,0 +1,40 @@ +package replace.usecase.temporaryfileupload + +import replace.datastore.FileStorage +import replace.datastore.TemporaryFileRepository +import replace.dto.TemporaryFileUploadDto +import replace.dto.toDto +import replace.model.TemporaryFile +import java.io.InputStream +import java.net.URLConnection +import java.util.UUID.randomUUID + +object CreateTemporaryFileUploadUseCase { + + suspend fun execute( + fileName: String, + input: InputStream, + temporaryFileRepository: TemporaryFileRepository, + fileStorage: FileStorage, + ): TemporaryFileUploadDto { + + val temporaryFileUploadPath = "temporary_uploads/${randomUUID()}" + + fileStorage.saveFile(temporaryFileUploadPath, input) + + val insertedTemporaryFile = temporaryFileRepository.insertOne( + TemporaryFile( + name = fileName.substringBeforeLast("."), + path = temporaryFileUploadPath, + mime = URLConnection.guessContentTypeFromName(fileName.lowercase()), + extension = fileName.substringAfterLast("."), + sizeInBytes = fileStorage.getFileSize(temporaryFileUploadPath), + createdAt = java.time.LocalDateTime.now(), + ) + ) + + checkNotNull(insertedTemporaryFile) { "Could not insert TemporaryFileUpload into Database" } + + return insertedTemporaryFile.toDto() + } +} diff --git a/application/src/jvmMain/kotlin/replace/usecase/temporaryfileupload/DeleteTemporaryFileUploadUseCase.kt b/application/src/jvmMain/kotlin/replace/usecase/temporaryfileupload/DeleteTemporaryFileUploadUseCase.kt new file mode 100644 index 00000000..cc962d59 --- /dev/null +++ b/application/src/jvmMain/kotlin/replace/usecase/temporaryfileupload/DeleteTemporaryFileUploadUseCase.kt @@ -0,0 +1,28 @@ +package replace.usecase.temporaryfileupload + +import org.bson.types.ObjectId +import replace.datastore.FileStorage +import replace.datastore.TemporaryFileRepository + +object DeleteTemporaryFileUploadUseCase { + + suspend fun execute( + temporaryFileUploadId: String, + temporaryFileRepository: TemporaryFileRepository, + fileStorage: FileStorage, + ) { + + if (!ObjectId.isValid(temporaryFileUploadId)) { + throw IllegalArgumentException("Id $temporaryFileUploadId is not a valid ObjectId") + } + + val temporaryFileUpload = temporaryFileRepository.findOneById(ObjectId(temporaryFileUploadId)) + ?: throw IllegalArgumentException("Temporary file upload with id $temporaryFileUploadId not found") + + if (fileStorage.deleteFile(temporaryFileUpload.path)) { + temporaryFileRepository.deleteOneById(ObjectId(temporaryFileUploadId)) + } else { + throw IllegalStateException("Could not delete temporary file upload with id $temporaryFileUploadId") + } + } +} diff --git a/application/src/jvmMain/kotlin/replace/usecase/temporaryfileupload/SaveTemporaryFileUploadPersistentUseCase.kt b/application/src/jvmMain/kotlin/replace/usecase/temporaryfileupload/SaveTemporaryFileUploadPersistentUseCase.kt new file mode 100644 index 00000000..2690e1bb --- /dev/null +++ b/application/src/jvmMain/kotlin/replace/usecase/temporaryfileupload/SaveTemporaryFileUploadPersistentUseCase.kt @@ -0,0 +1,33 @@ +package replace.usecase.temporaryfileupload + +import replace.datastore.FileRepository +import replace.datastore.FileStorage +import replace.datastore.TemporaryFileRepository +import replace.dto.FileDto +import replace.usecase.file.CreateFileUseCase + +object SaveTemporaryFileUploadPersistentUseCase { + + suspend fun execute( + temporaryFileUploadId: String, + temporaryFileRepository: TemporaryFileRepository, + fileRepository: FileRepository, + fileStorage: FileStorage, + ): FileDto { + + val file = CreateFileUseCase.execute( + temporaryFileUploadId, + temporaryFileRepository, + fileRepository, + fileStorage, + ) + + DeleteTemporaryFileUploadUseCase.execute( + temporaryFileUploadId, + temporaryFileRepository, + fileStorage, + ) + + return file + } +} diff --git a/domain/src/jvmMain/kotlin/replace/datastore/FileStorage.kt b/domain/src/jvmMain/kotlin/replace/datastore/FileStorage.kt new file mode 100644 index 00000000..21ccb976 --- /dev/null +++ b/domain/src/jvmMain/kotlin/replace/datastore/FileStorage.kt @@ -0,0 +1,18 @@ +package replace.datastore + +import java.io.InputStream + +interface FileStorage { + + suspend fun exists(path: String): Boolean + + suspend fun saveFile(path: String, input: InputStream): Boolean + + suspend fun deleteFile(path: String): Boolean + + suspend fun copyFile(from: String, to: String): Boolean + + suspend fun readFile(path: String): InputStream + + suspend fun getFileSize(path: String): Long +} diff --git a/domain/src/jvmMain/kotlin/replace/datastore/TemporaryFileRepository.kt b/domain/src/jvmMain/kotlin/replace/datastore/TemporaryFileRepository.kt new file mode 100644 index 00000000..302d94b9 --- /dev/null +++ b/domain/src/jvmMain/kotlin/replace/datastore/TemporaryFileRepository.kt @@ -0,0 +1,8 @@ +package replace.datastore + +import replace.model.TemporaryFile +import java.time.LocalDateTime + +interface TemporaryFileRepository : Repository { + suspend fun findOlderThan(datetime: LocalDateTime): List +} diff --git a/domain/src/jvmMain/kotlin/replace/datastore/aliases.kt b/domain/src/jvmMain/kotlin/replace/datastore/aliases.kt index 4405238c..a119d49e 100644 --- a/domain/src/jvmMain/kotlin/replace/datastore/aliases.kt +++ b/domain/src/jvmMain/kotlin/replace/datastore/aliases.kt @@ -1,9 +1,11 @@ package replace.datastore import replace.model.Booking +import replace.model.File import replace.model.Site // repositories that don't have any special methods typealias BookingRepository = Repository typealias SiteRepository = Repository +typealias FileRepository = Repository diff --git a/domain/src/jvmMain/kotlin/replace/model/File.kt b/domain/src/jvmMain/kotlin/replace/model/File.kt new file mode 100644 index 00000000..df0d87ed --- /dev/null +++ b/domain/src/jvmMain/kotlin/replace/model/File.kt @@ -0,0 +1,12 @@ +package replace.model + +import kotlinx.serialization.Serializable + +@Serializable +data class File( + val name: String, + val path: String, + val extension: String, + val sizeInBytes: Long, + val mime: String? = null, +) : ObjectWithId() diff --git a/domain/src/jvmMain/kotlin/replace/model/Floor.kt b/domain/src/jvmMain/kotlin/replace/model/Floor.kt index 37960e6b..e6eaf317 100644 --- a/domain/src/jvmMain/kotlin/replace/model/Floor.kt +++ b/domain/src/jvmMain/kotlin/replace/model/Floor.kt @@ -8,4 +8,5 @@ import org.bson.types.ObjectId data class Floor( val name: String, @Contextual val siteId: ObjectId, + @Contextual val planFileId: ObjectId? = null, ) : ObjectWithId() diff --git a/domain/src/jvmMain/kotlin/replace/model/TemporaryFile.kt b/domain/src/jvmMain/kotlin/replace/model/TemporaryFile.kt new file mode 100644 index 00000000..17ae3270 --- /dev/null +++ b/domain/src/jvmMain/kotlin/replace/model/TemporaryFile.kt @@ -0,0 +1,15 @@ +package replace.model + +import kotlinx.serialization.Contextual +import kotlinx.serialization.Serializable +import java.time.LocalDateTime + +@Serializable +data class TemporaryFile( + val name: String, + val path: String, + val extension: String, + val sizeInBytes: Long, + val mime: String? = null, + @Contextual val createdAt: LocalDateTime, +) : ObjectWithId() diff --git a/infrastructure/src/jvmMain/kotlin/replace/datastore/LocalFileStorage.kt b/infrastructure/src/jvmMain/kotlin/replace/datastore/LocalFileStorage.kt new file mode 100644 index 00000000..309e951b --- /dev/null +++ b/infrastructure/src/jvmMain/kotlin/replace/datastore/LocalFileStorage.kt @@ -0,0 +1,60 @@ +package replace.datastore + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.io.InputStream +import kotlin.io.path.Path +import kotlin.io.path.createDirectories + +class LocalFileStorage : FileStorage { + + private suspend fun ensureDirectoryExists(pathUri: String) = withContext(Dispatchers.IO) { + val path = Path(pathUri) + path.createDirectories() + } + + override suspend fun exists(path: String): Boolean = withContext(Dispatchers.IO) { + File("$STORAGE_PATH$path").exists() + } + + override suspend fun saveFile(path: String, input: InputStream): Boolean = withContext(Dispatchers.IO) { + + val file = File("$STORAGE_PATH$path") + + ensureDirectoryExists(file.parent) + + input.use { its -> + file.outputStream().buffered().use { + its.copyTo(it) + } + } + + file.exists() + } + + override suspend fun deleteFile(path: String): Boolean = withContext(Dispatchers.IO) { + File("$STORAGE_PATH$path").delete() + } + + override suspend fun copyFile(from: String, to: String): Boolean = withContext(Dispatchers.IO) { + val fromFile = File("$STORAGE_PATH$from") + val toFile = File("$STORAGE_PATH$to") + + ensureDirectoryExists(toFile.parent) + + fromFile.copyTo(toFile, overwrite = true).exists() + } + + override suspend fun readFile(path: String): InputStream = withContext(Dispatchers.IO) { + File("$STORAGE_PATH$path").inputStream() + } + + override suspend fun getFileSize(path: String): Long = withContext(Dispatchers.IO) { + File("$STORAGE_PATH$path").length() + } + + private companion object { + const val STORAGE_PATH = "storage/" + } +} diff --git a/infrastructure/src/jvmMain/kotlin/replace/datastore/MongoRepository.kt b/infrastructure/src/jvmMain/kotlin/replace/datastore/MongoRepository.kt index c08cdd98..cabbefe6 100644 --- a/infrastructure/src/jvmMain/kotlin/replace/datastore/MongoRepository.kt +++ b/infrastructure/src/jvmMain/kotlin/replace/datastore/MongoRepository.kt @@ -9,7 +9,7 @@ open class MongoRepository(protected val collection: Coroutine if (collection.insertOne(item).wasAcknowledged()) item else null override suspend fun updateOne(id: ObjectId, item: T): T? = - if (collection.updateOneById(id, item).modifiedCount > 0) item else null + if (collection.updateOneById(id, item).matchedCount > 0) item else null override suspend fun findOneById(id: ObjectId): T? = collection.findOneById(id) diff --git a/infrastructure/src/jvmMain/kotlin/replace/datastore/MongoTemporaryFileRepository.kt b/infrastructure/src/jvmMain/kotlin/replace/datastore/MongoTemporaryFileRepository.kt new file mode 100644 index 00000000..1cf82c0d --- /dev/null +++ b/infrastructure/src/jvmMain/kotlin/replace/datastore/MongoTemporaryFileRepository.kt @@ -0,0 +1,13 @@ +package replace.datastore + +import org.litote.kmongo.coroutine.CoroutineCollection +import org.litote.kmongo.lt +import replace.model.TemporaryFile +import java.time.LocalDateTime + +class MongoTemporaryFileRepository(collection: CoroutineCollection) : + MongoRepository(collection), TemporaryFileRepository { + + override suspend fun findOlderThan(datetime: LocalDateTime): List = + collection.find(TemporaryFile::createdAt lt datetime).toList() +} diff --git a/infrastructure/src/jvmMain/kotlin/replace/http/ApplicationModule.kt b/infrastructure/src/jvmMain/kotlin/replace/http/ApplicationModule.kt index 9a3a85c1..7676bf4d 100644 --- a/infrastructure/src/jvmMain/kotlin/replace/http/ApplicationModule.kt +++ b/infrastructure/src/jvmMain/kotlin/replace/http/ApplicationModule.kt @@ -22,16 +22,17 @@ import kotlinx.serialization.modules.contextual import org.litote.kmongo.coroutine.CoroutineDatabase import org.litote.kmongo.coroutine.coroutine import org.litote.kmongo.reactivestreams.KMongo +import replace.datastore.LocalFileStorage +import replace.datastore.MongoTemporaryFileRepository import replace.datastore.MongoUserRepository +import replace.job.DeleteOldTemporaryFileUploadsJob import replace.plugin.SinglePageApplication import replace.serializer.ObjectIdSerializer fun Application.applicationModule() { install(CORS) { anyHost() // TODO: Don't do this in production - allowHeader("SESSION_TOKEN") allowHeader(HttpHeaders.ContentType) - exposeHeader("SESSION_TOKEN") exposeHeader(HttpHeaders.ContentType) allowMethod(HttpMethod.Delete) allowMethod(HttpMethod.Post) @@ -78,7 +79,18 @@ fun Application.applicationModule() { swaggerUiEndpoint("/swagger", "/openapi") } - routeControllers(db) + val storage = LocalFileStorage() + + routeControllers(db, storage) + + val deleteOldTemporaryFileUploadsJob = DeleteOldTemporaryFileUploadsJob( + 1000 * 60 * 60 * 12, // 12 hours + 1000 * 60 * 60 * 24, // 24 hours + MongoTemporaryFileRepository(db.getCollection()), + storage + ) + + deleteOldTemporaryFileUploadsJob.dispatch() } fun getDB(config: HoconApplicationConfig): CoroutineDatabase { diff --git a/infrastructure/src/jvmMain/kotlin/replace/http/ControllerRouting.kt b/infrastructure/src/jvmMain/kotlin/replace/http/ControllerRouting.kt index 00f71cc0..463b92f3 100644 --- a/infrastructure/src/jvmMain/kotlin/replace/http/ControllerRouting.kt +++ b/infrastructure/src/jvmMain/kotlin/replace/http/ControllerRouting.kt @@ -4,24 +4,30 @@ import io.ktor.server.application.Application import io.ktor.server.auth.authenticate import io.ktor.server.routing.routing import org.litote.kmongo.coroutine.CoroutineDatabase +import replace.datastore.FileStorage import replace.http.controller.registerBookableEntityRoutes import replace.http.controller.registerBookableEntityTypeRoutes import replace.http.controller.registerBookingRoutes +import replace.http.controller.registerFileRoutes import replace.http.controller.registerFloorRoutes import replace.http.controller.registerSiteRoutes +import replace.http.controller.registerTemporaryFileUploadRoutes import replace.http.controller.registerUserRoutes fun Application.routeControllers( - db: CoroutineDatabase + db: CoroutineDatabase, + fileStorage: FileStorage ) { routing { authenticate { registerBookableEntityRoutes(db) registerBookableEntityTypeRoutes(db) registerBookingRoutes(db) - registerFloorRoutes(db) + registerFloorRoutes(db, fileStorage) registerSiteRoutes(db) registerUserRoutes(db) + registerFileRoutes(db, fileStorage) + registerTemporaryFileUploadRoutes(db, fileStorage) } } } diff --git a/infrastructure/src/jvmMain/kotlin/replace/http/SessionModule.kt b/infrastructure/src/jvmMain/kotlin/replace/http/SessionModule.kt index e3aa8f89..4e9878fa 100644 --- a/infrastructure/src/jvmMain/kotlin/replace/http/SessionModule.kt +++ b/infrastructure/src/jvmMain/kotlin/replace/http/SessionModule.kt @@ -4,9 +4,9 @@ import io.ktor.server.application.Application import io.ktor.server.application.createApplicationPlugin import io.ktor.server.application.install import io.ktor.server.sessions.Sessions +import io.ktor.server.sessions.cookie import io.ktor.server.sessions.directorySessionStorage import io.ktor.server.sessions.get -import io.ktor.server.sessions.header import io.ktor.server.sessions.sessions import io.ktor.server.sessions.set import replace.model.UserSession @@ -14,7 +14,11 @@ import java.io.File fun Application.sessionModule() { install(Sessions) { - header("SESSION_TOKEN", directorySessionStorage(File(".sessions"), cached = false)) + cookie("X-SESSION-TOKEN", directorySessionStorage(File("sessions"), cached = false)) { + cookie.path = "/" + cookie.httpOnly = true + cookie.extensions["SameSite"] = "strict" + } } val sessionTokenPlugin = createApplicationPlugin(name = "SessionTokenPlugin") { diff --git a/infrastructure/src/jvmMain/kotlin/replace/http/controller/FileController.kt b/infrastructure/src/jvmMain/kotlin/replace/http/controller/FileController.kt new file mode 100644 index 00000000..baa8aab7 --- /dev/null +++ b/infrastructure/src/jvmMain/kotlin/replace/http/controller/FileController.kt @@ -0,0 +1,54 @@ +package replace.http.controller + +import guru.zoroark.tegral.openapi.ktor.describe +import io.ktor.http.ContentDisposition +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.server.application.call +import io.ktor.server.resources.get +import io.ktor.server.response.header +import io.ktor.server.response.respondBytes +import io.ktor.server.response.respondText +import io.ktor.server.routing.Route +import io.ktor.server.routing.route +import org.bson.types.ObjectId +import org.litote.kmongo.coroutine.CoroutineDatabase +import replace.datastore.FileStorage +import replace.datastore.MongoRepository +import replace.model.File + +fun Route.registerFileRoutes(db: CoroutineDatabase, fileStorage: FileStorage) { + val fileRepository = MongoRepository(db.getCollection()) + + route("/api/file") { + get { route -> + if (!ObjectId.isValid(route.id)) { + return@get call.respondText("Id ${route.id} is not a valid ObjectId", status = HttpStatusCode.BadRequest) + } + + val dbResult = fileRepository.findOneById(ObjectId(route.id)) + ?: return@get call.respondText("Temporary file upload with id ${route.id} not found", status = HttpStatusCode.NotFound) + + if (!fileStorage.exists(dbResult.path)) { + return@get call.respondText("File with id ${route.id} not found", status = HttpStatusCode.NotFound) + } + + call.response.header( + HttpHeaders.ContentDisposition, + ContentDisposition.Inline.withParameter( + ContentDisposition.Parameters.FileName, "${dbResult.name}.${dbResult.extension}" + ).toString() + ) + + val mime = dbResult.mime ?: ContentType.Application.OctetStream.toString() + + call.respondBytes(ContentType.parse(mime)) { fileStorage.readFile(dbResult.path).readBytes() } + } describe { + description = "Gets a temporary file upload by id" + 200 response { + description = "The temporary file upload" + } + } + } +} diff --git a/infrastructure/src/jvmMain/kotlin/replace/http/controller/FloorController.kt b/infrastructure/src/jvmMain/kotlin/replace/http/controller/FloorController.kt index 045ace10..f8248bd1 100644 --- a/infrastructure/src/jvmMain/kotlin/replace/http/controller/FloorController.kt +++ b/infrastructure/src/jvmMain/kotlin/replace/http/controller/FloorController.kt @@ -13,20 +13,25 @@ import io.ktor.server.routing.put import io.ktor.server.routing.route import org.bson.types.ObjectId import org.litote.kmongo.coroutine.CoroutineDatabase +import replace.datastore.FileStorage import replace.datastore.MongoBookableEntityRepository import replace.datastore.MongoFloorRepository import replace.datastore.MongoRepository +import replace.datastore.MongoTemporaryFileRepository import replace.dto.FloorDto import replace.dto.toDto import replace.http.routeRepository +import replace.model.File import replace.model.Site import replace.usecase.floor.CreateFloorUseCase import replace.usecase.floor.UpdateFloorUseCase -fun Route.registerFloorRoutes(db: CoroutineDatabase) { +fun Route.registerFloorRoutes(db: CoroutineDatabase, fileStorage: FileStorage) { val floorRepository = MongoFloorRepository(db.getCollection()) val siteRepository = MongoRepository(db.getCollection()) val bookableEntityRepository = MongoBookableEntityRepository(db.getCollection()) + val fileRepository = MongoRepository(db.getCollection()) + val temporaryFileUploadRepository = MongoTemporaryFileRepository(db.getCollection()) route("/api/floor") { routeRepository(floorRepository) { @@ -35,7 +40,14 @@ fun Route.registerFloorRoutes(db: CoroutineDatabase) { post { executeUseCase { - CreateFloorUseCase.execute(it, floorRepository, siteRepository) + CreateFloorUseCase.execute( + it, + floorRepository, + siteRepository, + temporaryFileUploadRepository, + fileRepository, + fileStorage, + ) } } describe { description = "Creates a new floor" @@ -73,7 +85,7 @@ fun Route.registerFloorRoutes(db: CoroutineDatabase) { put { executeUseCase { - UpdateFloorUseCase.execute(it, floorRepository) + UpdateFloorUseCase.execute(it, floorRepository, temporaryFileUploadRepository, fileRepository, fileStorage) } } describe { description = "Updates a floor" diff --git a/infrastructure/src/jvmMain/kotlin/replace/http/controller/TemporaryFileUploadController.kt b/infrastructure/src/jvmMain/kotlin/replace/http/controller/TemporaryFileUploadController.kt new file mode 100644 index 00000000..976a363f --- /dev/null +++ b/infrastructure/src/jvmMain/kotlin/replace/http/controller/TemporaryFileUploadController.kt @@ -0,0 +1,120 @@ +package replace.http.controller + +import guru.zoroark.tegral.openapi.dsl.schema +import guru.zoroark.tegral.openapi.ktor.describe +import io.ktor.http.ContentDisposition +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.content.PartData +import io.ktor.http.content.forEachPart +import io.ktor.http.content.streamProvider +import io.ktor.server.application.call +import io.ktor.server.request.receiveMultipart +import io.ktor.server.resources.delete +import io.ktor.server.resources.get +import io.ktor.server.response.header +import io.ktor.server.response.respond +import io.ktor.server.response.respondBytes +import io.ktor.server.response.respondText +import io.ktor.server.routing.Route +import io.ktor.server.routing.post +import io.ktor.server.routing.route +import io.swagger.v3.oas.models.media.FileSchema +import io.swagger.v3.oas.models.media.MapSchema +import org.bson.types.ObjectId +import org.litote.kmongo.coroutine.CoroutineDatabase +import replace.datastore.FileStorage +import replace.datastore.MongoTemporaryFileRepository +import replace.dto.TemporaryFileUploadDto +import replace.usecase.temporaryfileupload.CreateTemporaryFileUploadUseCase +import replace.usecase.temporaryfileupload.DeleteTemporaryFileUploadUseCase +import java.util.UUID + +fun Route.registerTemporaryFileUploadRoutes(db: CoroutineDatabase, fileStorage: FileStorage) { + val temporaryFileUploadRepository = MongoTemporaryFileRepository(db.getCollection()) + + route("/api/temporary-file-upload") { + post { + executeUseCase { + val multipart = call.receiveMultipart() + val temporaryFileUploadDtos = mutableListOf() + + multipart.forEachPart { + + if (it !is PartData.FileItem) { + it.dispose() + return@forEachPart + } + + val name = it.originalFileName ?: UUID.randomUUID().toString() + val newFile = CreateTemporaryFileUploadUseCase.execute( + name, + it.streamProvider(), + temporaryFileUploadRepository, + fileStorage + ) + temporaryFileUploadDtos.add(newFile) + } + + return@post call.respond(temporaryFileUploadDtos) + } + } describe { + description = "Creates a new temporary file uploads" + body { + required = true + "multipart/form-data" content { + schema = MapSchema() + .addPatternProperty("*", FileSchema().description("The File to upload")) + } + } + 200 response { + description = "The created temporary file uploads" + json { + schema>() + } + } + } + + get { route -> + if (!ObjectId.isValid(route.id)) { + return@get call.respondText("Id ${route.id} is not a valid ObjectId", status = HttpStatusCode.BadRequest) + } + + val dbResult = temporaryFileUploadRepository.findOneById(ObjectId(route.id)) + ?: return@get call.respondText("Temporary file upload with id ${route.id} not found", status = HttpStatusCode.NotFound) + + if (!fileStorage.exists(dbResult.path)) { + return@get call.respondText("Temporary file upload with id ${route.id} not found", status = HttpStatusCode.NotFound) + } + + call.response.header( + HttpHeaders.ContentDisposition, + ContentDisposition.Inline.withParameter( + ContentDisposition.Parameters.FileName, "${dbResult.name}.${dbResult.extension}" + ).toString() + ) + + val mime = dbResult.mime ?: ContentType.Application.OctetStream.toString() + + call.respondBytes(ContentType.parse(mime)) { fileStorage.readFile(dbResult.path).readBytes() } + } describe { + description = "Gets a temporary file upload by id" + 200 response { + description = "The temporary file upload" + } + } + + delete { route -> + executeUseCase { + DeleteTemporaryFileUploadUseCase.execute(route.id, temporaryFileUploadRepository, fileStorage) + return@delete call.respond(HttpStatusCode.NoContent) + } + } describe { + description = "Deletes a temporary file upload by id" + 204 response { + description = "The temporary file upload was deleted" + } + } + } +} diff --git a/infrastructure/src/jvmMain/kotlin/replace/http/controller/UseCaseAdapter.kt b/infrastructure/src/jvmMain/kotlin/replace/http/controller/UseCaseAdapter.kt index e31f5bbc..4db0c44f 100644 --- a/infrastructure/src/jvmMain/kotlin/replace/http/controller/UseCaseAdapter.kt +++ b/infrastructure/src/jvmMain/kotlin/replace/http/controller/UseCaseAdapter.kt @@ -30,6 +30,13 @@ suspend inline fun PipelineContext.exec " ${e.stackTrace.joinToString("\n")}", status = HttpStatusCode.BadRequest ) + } catch (e: IllegalStateException) { + call.respondText( + "Something went horrible wrong.\n" + + "${e.cause?.message ?: e.message}\n" + + e.stackTrace.joinToString("\n"), + status = HttpStatusCode.InternalServerError + ) } catch (e: Exception) { call.respondText("[${e::class}]: An internal error occurred: ${e.message} \n ${e.stackTrace.joinToString("\n")}", status = HttpStatusCode.InternalServerError) } diff --git a/infrastructure/src/jvmMain/kotlin/replace/job/DeleteOldTemporaryFileUploadsJob.kt b/infrastructure/src/jvmMain/kotlin/replace/job/DeleteOldTemporaryFileUploadsJob.kt new file mode 100644 index 00000000..d16fbc10 --- /dev/null +++ b/infrastructure/src/jvmMain/kotlin/replace/job/DeleteOldTemporaryFileUploadsJob.kt @@ -0,0 +1,30 @@ +package replace.job + +import replace.datastore.FileStorage +import replace.datastore.TemporaryFileRepository +import replace.usecase.temporaryfileupload.DeleteTemporaryFileUploadUseCase +import java.time.LocalDateTime +import java.time.temporal.ChronoUnit + +class DeleteOldTemporaryFileUploadsJob( + interval: Long, + private val fileMaxAgeInMilliseconds: Long, + private val temporaryFileRepository: TemporaryFileRepository, + private val fileStorage: FileStorage, +) : SchedulableJob(interval) { + override suspend fun run() { + + try { + val oldTemporaryFileUploads = this.temporaryFileRepository.findOlderThan(LocalDateTime.now().minus(fileMaxAgeInMilliseconds, ChronoUnit.MILLIS)) + + oldTemporaryFileUploads.forEach { temporaryFileUpload -> + temporaryFileUpload.id?.let { + DeleteTemporaryFileUploadUseCase.execute(it.toHexString(), this.temporaryFileRepository, this.fileStorage) + } + } + } catch (e: Exception) { + println("Error while deleting old temporary file uploads: ${e.message}") + e.printStackTrace() + } + } +} diff --git a/infrastructure/src/jvmMain/kotlin/replace/job/SchedulableJob.kt b/infrastructure/src/jvmMain/kotlin/replace/job/SchedulableJob.kt new file mode 100644 index 00000000..69c5f69f --- /dev/null +++ b/infrastructure/src/jvmMain/kotlin/replace/job/SchedulableJob.kt @@ -0,0 +1,39 @@ +package replace.job + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.util.concurrent.Executors +import kotlin.coroutines.CoroutineContext + +abstract class SchedulableJob( + private val interval: Long, + private val initialDelay: Long? = null, +) : CoroutineScope { + private val job: Job = Job() + + private val singleThreadExecutor = Executors.newSingleThreadExecutor() + + override val coroutineContext: CoroutineContext + get() = job + singleThreadExecutor.asCoroutineDispatcher() + + fun cancel() { + job.cancel() + singleThreadExecutor.shutdown() + } + + fun dispatch() = launch { + initialDelay?.let { + delay(it) + } + + while (true) { + run() + delay(interval) + } + } + + protected abstract suspend fun run() +} diff --git a/web/src/angular/angular.json b/web/src/angular/angular.json index 711a5ef1..c5c061ef 100644 --- a/web/src/angular/angular.json +++ b/web/src/angular/angular.json @@ -38,7 +38,12 @@ "tsConfig": "tsconfig.app.json", "inlineStyleLanguage": "scss", "assets": ["src/favicon.ico", "src/assets"], - "styles": ["@angular/material/prebuilt-themes/indigo-pink.css", "src/styles.scss"], + "styles": [ + "@angular/material/prebuilt-themes/indigo-pink.css", + "src/styles.scss", + "./node_modules/filepond/dist/filepond.min.css", + "./node_modules/filepond-plugin-image-preview/dist/filepond-plugin-image-preview.css" + ], "scripts": [] }, "configurations": { @@ -94,7 +99,11 @@ "tsConfig": "tsconfig.spec.json", "inlineStyleLanguage": "scss", "assets": ["src/favicon.ico", "src/assets"], - "styles": ["@angular/material/prebuilt-themes/indigo-pink.css", "src/styles.scss"], + "styles": [ + "@angular/material/prebuilt-themes/indigo-pink.css", + "src/styles.scss", + "./node_modules/filepond/dist/filepond.min.css" + ], "scripts": [] } }, diff --git a/web/src/angular/package.json b/web/src/angular/package.json index e776b95e..4904934b 100644 --- a/web/src/angular/package.json +++ b/web/src/angular/package.json @@ -26,7 +26,11 @@ "@tailwindcss/forms": "^0.5.3", "@types/lodash": "^4.14.191", "@types/uuid": "^9.0.0", + "filepond": "^4.30.4", + "filepond-plugin-file-validate-type": "^1.2.8", + "filepond-plugin-image-preview": "^4.6.11", "loadash": "^1.0.0", + "ngx-filepond": "^6.0.1", "rxjs": "~7.5.0", "tslib": "^2.3.0", "type-fest": "^3.3.0", diff --git a/web/src/angular/src/app/core/core.module.ts b/web/src/angular/src/app/core/core.module.ts index 58debcec..022faeee 100644 --- a/web/src/angular/src/app/core/core.module.ts +++ b/web/src/angular/src/app/core/core.module.ts @@ -1,21 +1,13 @@ import { CommonModule } from "@angular/common" -import { HttpClientModule, HTTP_INTERCEPTORS } from "@angular/common/http" +import { HttpClientModule } from "@angular/common/http" import { NgModule, Optional, SkipSelf } from "@angular/core" import { AuthGuard } from "./guards/auth.guard" -import { SessionInterceptor } from "./interceptors/session.interceptor" @NgModule({ declarations: [], imports: [CommonModule, HttpClientModule], - providers: [ - { - provide: HTTP_INTERCEPTORS, - useClass: SessionInterceptor, - multi: true, - }, - AuthGuard, - ], + providers: [AuthGuard], }) export class CoreModule { constructor(@Optional() @SkipSelf() coreModule: CoreModule | null) { diff --git a/web/src/angular/src/app/core/interceptors/session.interceptor.ts b/web/src/angular/src/app/core/interceptors/session.interceptor.ts deleted file mode 100644 index acb25443..00000000 --- a/web/src/angular/src/app/core/interceptors/session.interceptor.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { HttpRequest, HttpHandler, HttpEvent, HttpInterceptor, HttpEventType, HttpErrorResponse } from "@angular/common/http" -import { Injectable } from "@angular/core" -import { Observable, tap } from "rxjs" - -import { SessionService } from "../services/session.service" - -@Injectable() -export class SessionInterceptor implements HttpInterceptor { - constructor(private readonly sessionService: SessionService) {} - - intercept(request: HttpRequest, next: HttpHandler): Observable> { - const sessionToken = this.sessionService.sessionToken - - if (sessionToken !== null) { - request = request.clone({ - setHeaders: { - SESSION_TOKEN: sessionToken, - }, - }) - } - - return next.handle(request).pipe( - tap({ - next: (event: HttpEvent) => { - if (event.type !== HttpEventType.ResponseHeader) { - return event - } - - if (!event.headers.has("SESSION_TOKEN")) { - return event - } - - this.sessionService.sessionToken = event.headers.get("SESSION_TOKEN") - return event - }, - error: (error) => { - if (!(error instanceof HttpErrorResponse)) { - return - } - - this.sessionService.sessionToken = error.headers.get("SESSION_TOKEN") - }, - }), - ) - } -} diff --git a/web/src/angular/src/app/core/services/api.service.ts b/web/src/angular/src/app/core/services/api.service.ts index 0949edd3..b72e1857 100644 --- a/web/src/angular/src/app/core/services/api.service.ts +++ b/web/src/angular/src/app/core/services/api.service.ts @@ -1,7 +1,9 @@ -import { HttpClient } from "@angular/common/http" +import { HttpClient, HttpRequest } from "@angular/common/http" import { Injectable } from "@angular/core" import { firstValueFrom } from "rxjs" -import { BookableEntity, Floor, Site } from "types" +import { BookableEntity, Floor, Site, TemporaryFileUpload } from "types" + +import { randomString, urlForFile, urlForTemporaryFileUpload } from "src/app/util" @Injectable({ providedIn: "root", @@ -64,4 +66,39 @@ export class ApiService { public async updateBookableEntity(entity: BookableEntity) { return await firstValueFrom(this.http.put("/api/bookable-entity", entity)) } + + public createTemporaryFileUploads(files: File[]) { + const formData = new FormData() + + for (const file of files) { + formData.append(randomString(8), file, file.name) + } + + const req = new HttpRequest("POST", "/api/temporary-file-upload", formData, { + reportProgress: true, + }) + + return this.http.request(req) + } + + public getTemporaryFileUpload(id: string) { + const req = new HttpRequest("GET", urlForTemporaryFileUpload(id), { + responseType: "blob", + }) + return this.http.request(req) + } + + public deleteTemporaryFileUpload(id: string) { + const req = new HttpRequest("DELETE", urlForTemporaryFileUpload(id)) + + return this.http.request(req) + } + + public getFile(id: string) { + const req = new HttpRequest("GET", urlForFile(id), { + responseType: "blob", + }) + + return this.http.request(req) + } } diff --git a/web/src/angular/src/app/core/services/session.service.ts b/web/src/angular/src/app/core/services/session.service.ts deleted file mode 100644 index f368ddd0..00000000 --- a/web/src/angular/src/app/core/services/session.service.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Injectable } from "@angular/core" - -@Injectable({ - providedIn: "root", -}) -export class SessionService { - get sessionToken(): string | null { - return localStorage.getItem("SESSION_TOKEN") - } - - set sessionToken(value: string | null) { - if (value === null) { - localStorage.removeItem("SESSION_TOKEN") - } else { - localStorage.setItem("SESSION_TOKEN", value) - } - } -} diff --git a/web/src/angular/src/app/features/floor/pages/edit/edit.component.html b/web/src/angular/src/app/features/floor/pages/edit/edit.component.html index 693846d0..37d9d231 100644 --- a/web/src/angular/src/app/features/floor/pages/edit/edit.component.html +++ b/web/src/angular/src/app/features/floor/pages/edit/edit.component.html @@ -14,6 +14,13 @@ /> Der Name des Stockwerkes +

Grundriss

+ diff --git a/web/src/angular/src/app/features/floor/pages/edit/edit.component.ts b/web/src/angular/src/app/features/floor/pages/edit/edit.component.ts index 32ce3574..606ae76b 100644 --- a/web/src/angular/src/app/features/floor/pages/edit/edit.component.ts +++ b/web/src/angular/src/app/features/floor/pages/edit/edit.component.ts @@ -6,6 +6,7 @@ import { SetOptional } from "type-fest" import { BookableEntity, Floor } from "types" import { ApiService } from "src/app/core/services/api.service" +import { FileUpload } from "src/app/shared/components/file-upload/file-upload.component" import { DataLoader, Form } from "src/app/util" @Component({ @@ -16,6 +17,7 @@ import { DataLoader, Form } from "src/app/util" export class EditComponent implements OnDestroy { title = "" form: Form | undefined = undefined + floor = new DataLoader() bookableEntities = new DataLoader() editingBookableEntity: SetOptional | undefined = undefined @@ -26,17 +28,39 @@ export class EditComponent implements OnDestroy { private readonly route: ActivatedRoute, private readonly snackBar: MatSnackBar, ) { - this.routeSub = route.params.subscribe(async (params) => { - this.form = new Form(await api.getFloor(params["id"])) + this.floor.subscribe((floor) => { + this.form = new Form(floor) this.form.useSnackbar(snackBar) this.title = `Stockwerk ${this.form.data.name} bearbeiten` + }) + this.routeSub = route.params.subscribe(async (params) => { + this.floor.source(() => api.getFloor(params["id"])).refresh() this.bookableEntities.source(() => api.getBookableEntities(params["id"])).refresh() }) } - public onSubmit() { - this.form?.submit((data) => this.api.updateFloor(data)) + public get files(): FileUpload[] { + const planFile = this.form?.data.planFile + + if (planFile === undefined || planFile === null) { + return [] + } + + return [planFile] + } + + public set files(newFiles: FileUpload[]) { + if (this.form === undefined) { + return + } + + this.form.data.planFile = newFiles.at(0) ?? null + } + + public async onSubmit() { + await this.form?.submit((data) => this.api.updateFloor(data)) + this.floor.refresh() } ngOnDestroy(): void { @@ -76,4 +100,18 @@ export class EditComponent implements OnDestroy { }) } } + + public get initialFiles(): string[] { + const planFile = this.form?.data.planFile + + if (planFile === undefined || planFile === null || planFile.temporary) { + return [] + } + + return [planFile.id] + } + + public onFilesUploaded(files: FileUpload[]) { + this.files = files + } } diff --git a/web/src/angular/src/app/features/site/components/create-or-update-floor/create-or-update-floor.component.ts b/web/src/angular/src/app/features/site/components/create-or-update-floor/create-or-update-floor.component.ts index d956c638..a9113d5a 100644 --- a/web/src/angular/src/app/features/site/components/create-or-update-floor/create-or-update-floor.component.ts +++ b/web/src/angular/src/app/features/site/components/create-or-update-floor/create-or-update-floor.component.ts @@ -9,7 +9,7 @@ import { Floor } from "types" }) export class CreateOrUpdateFloorComponent implements OnChanges { @Input() floor!: SetOptional - floorToEdit: SetOptional = { name: "" } + floorToEdit: SetOptional = { name: "", planFile: null } @Output() submitFloor = new EventEmitter>() diff --git a/web/src/angular/src/app/features/site/pages/edit/edit.component.ts b/web/src/angular/src/app/features/site/pages/edit/edit.component.ts index 2fcc5b82..13f3d47e 100644 --- a/web/src/angular/src/app/features/site/pages/edit/edit.component.ts +++ b/web/src/angular/src/app/features/site/pages/edit/edit.component.ts @@ -44,7 +44,7 @@ export class EditComponent implements OnDestroy { } public onCreateFloor() { - this.editingFloor = { name: "" } + this.editingFloor = { name: "", planFile: null } } public onSubmitFloor(floor: SetOptional) { diff --git a/web/src/angular/src/app/shared/components/crud-card/crud-card.component.ts b/web/src/angular/src/app/shared/components/crud-card/crud-card.component.ts index 1d2147d1..b95c9f1a 100644 --- a/web/src/angular/src/app/shared/components/crud-card/crud-card.component.ts +++ b/web/src/angular/src/app/shared/components/crud-card/crud-card.component.ts @@ -9,11 +9,10 @@ import { HeaderDirective } from "../../directives/header.directive" styles: [], }) export class CrudCardComponent { - @Input() title: string | undefined @Input() saveText: string | undefined @Input() deletable = false - @Output() save = new EventEmitter() + @Output() save = new EventEmitter() @Output() delete = new EventEmitter() @ContentChild(HeaderDirective) header: HeaderDirective | undefined @@ -27,7 +26,7 @@ export class CrudCardComponent { event.preventDefault() - this.save.emit() + this.save.emit(event) } public emitDelete() { diff --git a/web/src/angular/src/app/shared/components/file-upload/file-upload.component.html b/web/src/angular/src/app/shared/components/file-upload/file-upload.component.html new file mode 100644 index 00000000..1451fa8b --- /dev/null +++ b/web/src/angular/src/app/shared/components/file-upload/file-upload.component.html @@ -0,0 +1,8 @@ + + diff --git a/web/src/angular/src/app/shared/components/file-upload/file-upload.component.ts b/web/src/angular/src/app/shared/components/file-upload/file-upload.component.ts new file mode 100644 index 00000000..f656bfd9 --- /dev/null +++ b/web/src/angular/src/app/shared/components/file-upload/file-upload.component.ts @@ -0,0 +1,137 @@ +import { HttpEventType } from "@angular/common/http" +import { Component, EventEmitter, Input, Output } from "@angular/core" +// eslint-disable-next-line import/named +import { FilePondOptions, FilePondFile, FileStatus, FileOrigin, FilePondInitialFile, FilePond } from "filepond" +import { File } from "types" + +import { ApiService } from "src/app/core/services/api.service" + +export type FileUpload = { + id: string + temporary: boolean +} + +type UpdateFilesEvent = { + filepond: unknown + items: FilePondFile[] +} + +@Component({ + selector: "file-upload", + templateUrl: "./file-upload.component.html", + styles: [], +}) +export class FileUploadComponent { + @Input() placeholder = "Drop files here..." + @Input() mimeTypes: string[] = [] + @Input() initialFiles: (string | File)[] = [] + @Input() maxFiles: number | undefined = undefined + + @Output() filesUpdated = new EventEmitter() + + constructor(private readonly apiService: ApiService) {} + + protected get initialFileIds(): FilePondInitialFile[] { + return this.initialFiles.map((file) => { + return { + source: typeof file === "string" ? file : file.id, + options: { + type: "local", + }, + } + }) + } + + public get filePondOptions(): FilePondOptions { + const pondOptions: FilePondOptions = { + allowMultiple: false, + labelIdle: this.placeholder, + acceptedFileTypes: this.mimeTypes, + allowFileTypeValidation: this.mimeTypes.length > 0, + chunkUploads: false, + maxFiles: this.maxFiles, + + server: { + process: { + url: "/api/temporary-file-upload", + method: "POST", + onload: (response) => { + return JSON.parse(response).at(0)?.id + }, + }, + restore: (uniqueFileId, load, error) => { + this.apiService.getTemporaryFileUpload(uniqueFileId).subscribe((event) => { + if (event.type === HttpEventType.ResponseHeader) { + if (event.ok) { + return + } + + error(`[${event.status}]: ${event.statusText}`) + } + + if (event.type === HttpEventType.Response && event.ok && event.body !== null) { + load(event.body) + } + }) + }, + revert: (uniqueFileId, load, error) => { + this.apiService.deleteTemporaryFileUpload(uniqueFileId).subscribe((event) => { + if (event.type === HttpEventType.ResponseHeader) { + if (event.ok) { + load() + return + } + + error(`[${event.status}]: ${event.statusText}`) + } + }) + }, + + load: (source, load, error, progress, abort, headers) => { + this.apiService.getFile(source).subscribe((event) => { + if (event.type === HttpEventType.DownloadProgress) { + progress(true, event.loaded, event.total ?? 0) + } + + if (event.type === HttpEventType.ResponseHeader) { + if (event.ok) { + return + } + + error(`[${event.status}]: ${event.statusText}`) + } + + if (event.type === HttpEventType.Response && event.ok && event.body !== null) { + load(event.body) + } + + if (event.type === HttpEventType.Response && !event.ok) { + error(`[${event.status}]: ${event.statusText}`) + } + }) + }, + }, + } + return pondOptions + } + + pondOnFileUpdate(event: UpdateFilesEvent) { + this.updateFilePondFiles(event.items) + } + + updateFilePondFiles(filePondFiles: FilePondFile[]) { + const files: FileUpload[] = filePondFiles + .filter((file) => { + return file.status === FileStatus.PROCESSING_COMPLETE || file.status === FileStatus.IDLE + }) + .map((file) => { + return { id: file.serverId, temporary: file.origin !== FileOrigin.LOCAL } + }) + + this.filesUpdated.emit(files) + } + + pondOnFileProcessed(event: { pond: FilePond }) { + this.updateFilePondFiles(event.pond.getFiles()) + } +} diff --git a/web/src/angular/src/app/shared/shared.module.ts b/web/src/angular/src/app/shared/shared.module.ts index d32e8f67..587c5ce8 100644 --- a/web/src/angular/src/app/shared/shared.module.ts +++ b/web/src/angular/src/app/shared/shared.module.ts @@ -1,6 +1,9 @@ import { CommonModule } from "@angular/common" import { NgModule } from "@angular/core" import { RouterModule } from "@angular/router" +import * as FilePondPluginFileValidateType from "filepond-plugin-file-validate-type" +import FilePondPluginImagePreview from "filepond-plugin-image-preview" +import { FilePondModule, registerPlugin } from "ngx-filepond" import { MaterialModule } from "../material/material.module" import { AndrenaLogoComponent } from "./components/andrena-logo/andrena-logo.component" @@ -9,6 +12,7 @@ import { CrudCardComponent } from "./components/crud-card/crud-card.component" import { CrudLayoutComponent } from "./components/crud-layout/crud-layout.component" import { DataTableComponent } from "./components/data-table/data-table.component" import { EmptyStateComponent } from "./components/empty-state/empty-state.component" +import { FileUploadComponent } from "./components/file-upload/file-upload.component" import { LoadingStateComponent } from "./components/loading-state/loading-state.component" import { TextButtonComponent } from "./components/text-button/text-button.component" import { UserCardComponent } from "./components/user-card/user-card.component" @@ -19,6 +23,12 @@ import { HeaderDirective } from "./directives/header.directive" import { IconDirective } from "./directives/icon.directive" import { UserLayoutComponent } from "./layouts/user-layout/user-layout.component" +// Register the plugin +registerPlugin(FilePondPluginImagePreview) +// import filepond module + +registerPlugin(FilePondPluginFileValidateType) + @NgModule({ declarations: [ AndrenaLogoComponent, @@ -36,6 +46,7 @@ import { UserLayoutComponent } from "./layouts/user-layout/user-layout.component EmptyStateComponent, ActionsDirective, IconDirective, + FileUploadComponent, ], exports: [ AndrenaLogoComponent, @@ -53,7 +64,8 @@ import { UserLayoutComponent } from "./layouts/user-layout/user-layout.component EmptyStateComponent, ActionsDirective, IconDirective, + FileUploadComponent, ], - imports: [CommonModule, MaterialModule, RouterModule], + imports: [CommonModule, MaterialModule, RouterModule, FilePondModule], }) export class SharedModule {} diff --git a/web/src/angular/src/app/util/DataLoader.ts b/web/src/angular/src/app/util/DataLoader.ts index d65e828b..46a9f624 100644 --- a/web/src/angular/src/app/util/DataLoader.ts +++ b/web/src/angular/src/app/util/DataLoader.ts @@ -2,6 +2,7 @@ export default class DataLoader { protected _data: undefined | T protected _processing = false protected _dataSource: (() => Promise) | undefined + protected _subscriptions: ((data: T) => void)[] = [] public constructor(dataSource?: () => Promise) { this._dataSource = dataSource @@ -11,6 +12,16 @@ export default class DataLoader { return new DataLoader(dataSource) } + public subscribe(subscription: (data: T) => void): () => void { + this._subscriptions.push(subscription) + return this.unsubscribe.bind(this, subscription) + } + + public unsubscribe(subscription: (data: T) => void) { + this._subscriptions = this._subscriptions.filter((s) => s !== subscription) + return this + } + public source(dataSource: () => Promise) { this._dataSource = dataSource return this @@ -30,6 +41,7 @@ export default class DataLoader { }) .finally(() => { this._processing = false + this._subscriptions.forEach((s) => s(this._data as T)) }) return this diff --git a/web/src/angular/src/app/util/FileUtil.ts b/web/src/angular/src/app/util/FileUtil.ts new file mode 100644 index 00000000..2ae00f18 --- /dev/null +++ b/web/src/angular/src/app/util/FileUtil.ts @@ -0,0 +1,13 @@ +import { File, TemporaryFileUpload } from "types" + +export function urlForFile(file: File | string): string { + const fileId = typeof file === "string" ? file : file.id + + return `/api/file/${fileId}` +} + +export function urlForTemporaryFileUpload(file: TemporaryFileUpload | string): string { + const fileId = typeof file === "string" ? file : file.id + + return `/api/temporary-file-upload/${fileId}` +} diff --git a/web/src/angular/src/app/util/StringUtil.ts b/web/src/angular/src/app/util/StringUtil.ts new file mode 100644 index 00000000..b866cb5e --- /dev/null +++ b/web/src/angular/src/app/util/StringUtil.ts @@ -0,0 +1,11 @@ +export function randomString(length = 16, radix = 36): string { + const result = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER) + .toString(radix) + .slice(0, length) + + if (result.length < length) { + return `${result}${randomString(length - result.length, radix)}` + } + + return result +} diff --git a/web/src/angular/src/app/util/index.ts b/web/src/angular/src/app/util/index.ts index 6b919fd8..b2709db6 100644 --- a/web/src/angular/src/app/util/index.ts +++ b/web/src/angular/src/app/util/index.ts @@ -1,2 +1,4 @@ export { default as Form } from "./Form" export { default as DataLoader } from "./DataLoader" +export * from "./FileUtil" +export * from "./StringUtil" diff --git a/web/src/angular/types/File.ts b/web/src/angular/types/File.ts new file mode 100644 index 00000000..a156431d --- /dev/null +++ b/web/src/angular/types/File.ts @@ -0,0 +1,8 @@ +export default interface File { + id: string + name: string + path: string + mime: string | null + extension: string + sizeInBytes: number +} diff --git a/web/src/angular/types/FileUpload.ts b/web/src/angular/types/FileUpload.ts new file mode 100644 index 00000000..80c2e9a1 --- /dev/null +++ b/web/src/angular/types/FileUpload.ts @@ -0,0 +1,5 @@ +import { Model } from "." + +export default interface FileUpload extends Model { + temporary: boolean +} diff --git a/web/src/angular/types/Floor.ts b/web/src/angular/types/Floor.ts index b1026547..a22f1747 100644 --- a/web/src/angular/types/Floor.ts +++ b/web/src/angular/types/Floor.ts @@ -1,6 +1,7 @@ -import { Model } from "." +import { FileUpload, Model } from "." export default interface Floor extends Model { name: string siteId: string + planFile: FileUpload | null } diff --git a/web/src/angular/types/TemporaryFileUpload.ts b/web/src/angular/types/TemporaryFileUpload.ts new file mode 100644 index 00000000..1f8c20d8 --- /dev/null +++ b/web/src/angular/types/TemporaryFileUpload.ts @@ -0,0 +1,9 @@ +export default interface File { + id: string + name: string + path: string + mime: string | null + extension: string + sizeInBytes: number + createdAt: string +} diff --git a/web/src/angular/types/index.ts b/web/src/angular/types/index.ts index 6dcdd3bf..216eaea4 100644 --- a/web/src/angular/types/index.ts +++ b/web/src/angular/types/index.ts @@ -4,3 +4,6 @@ export { default as User } from "./User" export { default as Model } from "./Model" export { default as BookableEntity } from "./BookableEntity" export { default as BookableEntityType } from "./BookableEntityType" +export { default as File } from "./File" +export { default as TemporaryFileUpload } from "./TemporaryFileUpload" +export { default as FileUpload } from "./FileUpload" diff --git a/web/src/angular/yarn.lock b/web/src/angular/yarn.lock index dcca3f37..87ee77ab 100644 --- a/web/src/angular/yarn.lock +++ b/web/src/angular/yarn.lock @@ -3686,6 +3686,9 @@ __metadata: autoprefixer: ^10.4.13 eslint: ^8.28.0 eslint-plugin-import: ^2.26.0 + filepond: ^4.30.4 + filepond-plugin-file-validate-type: ^1.2.8 + filepond-plugin-image-preview: ^4.6.11 jasmine-core: ~4.5.0 karma: ~6.4.0 karma-chrome-launcher: ~3.1.0 @@ -3693,6 +3696,7 @@ __metadata: karma-jasmine: ~5.1.0 karma-jasmine-html-reporter: ~2.0.0 loadash: ^1.0.0 + ngx-filepond: ^6.0.1 postcss: ^8.4.19 rxjs: ~7.5.0 tailwindcss: ^3.2.4 @@ -5696,6 +5700,31 @@ __metadata: languageName: node linkType: hard +"filepond-plugin-file-validate-type@npm:^1.2.8": + version: 1.2.8 + resolution: "filepond-plugin-file-validate-type@npm:1.2.8" + peerDependencies: + filepond: ">=1.x <5.x" + checksum: 5a8334323d0f9ef154f4f5788125e8b724ab3e39f23ccb35defad8d4a0f324f3097c280858f9662278576a9e9929065739c455f3bfe7bab1827bd1d9fe32d204 + languageName: node + linkType: hard + +"filepond-plugin-image-preview@npm:^4.6.11": + version: 4.6.11 + resolution: "filepond-plugin-image-preview@npm:4.6.11" + peerDependencies: + filepond: ">=4.x <5.x" + checksum: 436ee3396910efe7973bbaf4578eb2a2b19246ea4b94e6d6265361b37919c978d4ceed8fbd6fa5b8f24fe848ae5e6270cae243a4ac5f84744960fa107bb8da4e + languageName: node + linkType: hard + +"filepond@npm:^4.30.4": + version: 4.30.4 + resolution: "filepond@npm:4.30.4" + checksum: ec1c06a11ceda231e5589f52307fa2f34b9c67c14b22c2be7adde227159f32e9ed1aa0e0a32fbca4e08a837b6714084e9f629c097a669fc1c3076be8c5f3c295 + languageName: node + linkType: hard + "fill-range@npm:^7.0.1": version: 7.0.1 resolution: "fill-range@npm:7.0.1" @@ -7759,6 +7788,19 @@ __metadata: languageName: node linkType: hard +"ngx-filepond@npm:^6.0.1": + version: 6.0.1 + resolution: "ngx-filepond@npm:6.0.1" + dependencies: + tslib: ^1.10.0 + peerDependencies: + "@angular/common": ">=9.0.0" + "@angular/core": ">=9.0.0" + filepond: ">=4.19.1 <5.x" + checksum: 7fea73b43c3a6a92e4af7c781a4196e930a95964f948691dfe999145c70da3e03c278b9248a16ca34fdf66f86e582032d30c013ee2d47cb2cadb784d751289b6 + languageName: node + linkType: hard + "nice-napi@npm:^1.0.2": version: 1.0.2 resolution: "nice-napi@npm:1.0.2" @@ -9844,7 +9886,7 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^1.8.1, tslib@npm:^1.9.0": +"tslib@npm:^1.10.0, tslib@npm:^1.8.1, tslib@npm:^1.9.0": version: 1.14.1 resolution: "tslib@npm:1.14.1" checksum: dbe628ef87f66691d5d2959b3e41b9ca0045c3ee3c7c7b906cc1e328b39f199bb1ad9e671c39025bd56122ac57dfbf7385a94843b1cc07c60a4db74795829acd