diff --git a/backend/src/main/kotlin/fr/gouv/cnsp/monitorfish/domain/entities/prior_notification/PriorNotificationDocument.kt b/backend/src/main/kotlin/fr/gouv/cnsp/monitorfish/domain/entities/prior_notification/PriorNotificationDocument.kt new file mode 100644 index 0000000000..b85daec526 --- /dev/null +++ b/backend/src/main/kotlin/fr/gouv/cnsp/monitorfish/domain/entities/prior_notification/PriorNotificationDocument.kt @@ -0,0 +1,44 @@ +package fr.gouv.cnsp.monitorfish.domain.entities.prior_notification + +import fr.gouv.cnsp.monitorfish.utils.CustomZonedDateTime + +data class PriorNotificationDocument( + val id: String?, + val content: ByteArray, + val createdAt: CustomZonedDateTime, + val fileName: String, + val isManualPriorNotification: Boolean, + val mimeType: String, + val reportId: String, + val updatedAt: CustomZonedDateTime, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as PriorNotificationDocument + + if (id != other.id) return false + if (!content.contentEquals(other.content)) return false + if (createdAt != other.createdAt) return false + if (fileName != other.fileName) return false + if (isManualPriorNotification != other.isManualPriorNotification) return false + if (mimeType != other.mimeType) return false + if (reportId != other.reportId) return false + if (updatedAt != other.updatedAt) return false + + return true + } + + override fun hashCode(): Int { + var result = id?.hashCode() ?: 0 + result = 31 * result + content.contentHashCode() + result = 31 * result + createdAt.hashCode() + result = 31 * result + fileName.hashCode() + result = 31 * result + isManualPriorNotification.hashCode() + result = 31 * result + mimeType.hashCode() + result = 31 * result + reportId.hashCode() + result = 31 * result + updatedAt.hashCode() + return result + } +} diff --git a/backend/src/main/kotlin/fr/gouv/cnsp/monitorfish/domain/entities/prior_notification/PriorNotificationIdentifier.kt b/backend/src/main/kotlin/fr/gouv/cnsp/monitorfish/domain/entities/prior_notification/PriorNotificationIdentifier.kt new file mode 100644 index 0000000000..8a05240251 --- /dev/null +++ b/backend/src/main/kotlin/fr/gouv/cnsp/monitorfish/domain/entities/prior_notification/PriorNotificationIdentifier.kt @@ -0,0 +1,9 @@ +package fr.gouv.cnsp.monitorfish.domain.entities.prior_notification + +import java.time.ZonedDateTime + +data class PriorNotificationIdentifier( + val reportId: String, + val operationDate: ZonedDateTime, + val isManualPriorNotification: Boolean, +) diff --git a/backend/src/main/kotlin/fr/gouv/cnsp/monitorfish/domain/repositories/PriorNotificationUploadRepository.kt b/backend/src/main/kotlin/fr/gouv/cnsp/monitorfish/domain/repositories/PriorNotificationUploadRepository.kt new file mode 100644 index 0000000000..0c5633d9d0 --- /dev/null +++ b/backend/src/main/kotlin/fr/gouv/cnsp/monitorfish/domain/repositories/PriorNotificationUploadRepository.kt @@ -0,0 +1,13 @@ +package fr.gouv.cnsp.monitorfish.domain.repositories + +import fr.gouv.cnsp.monitorfish.domain.entities.prior_notification.PriorNotificationDocument + +interface PriorNotificationUploadRepository { + fun findAllByReportId(reportId: String): List + + fun findById(id: String): PriorNotificationDocument + + fun save(newPriorNotificationDocument: PriorNotificationDocument): PriorNotificationDocument + + fun deleteById(id: String) +} diff --git a/backend/src/main/kotlin/fr/gouv/cnsp/monitorfish/domain/use_cases/prior_notification/CreatePriorNotificationUpload.kt b/backend/src/main/kotlin/fr/gouv/cnsp/monitorfish/domain/use_cases/prior_notification/CreatePriorNotificationUpload.kt new file mode 100644 index 0000000000..d438afbce7 --- /dev/null +++ b/backend/src/main/kotlin/fr/gouv/cnsp/monitorfish/domain/use_cases/prior_notification/CreatePriorNotificationUpload.kt @@ -0,0 +1,50 @@ +package fr.gouv.cnsp.monitorfish.domain.use_cases.prior_notification + +import fr.gouv.cnsp.monitorfish.config.UseCase +import fr.gouv.cnsp.monitorfish.domain.entities.prior_notification.PriorNotificationDocument +import fr.gouv.cnsp.monitorfish.domain.entities.prior_notification.PriorNotificationIdentifier +import fr.gouv.cnsp.monitorfish.domain.repositories.LogbookReportRepository +import fr.gouv.cnsp.monitorfish.domain.repositories.ManualPriorNotificationRepository +import fr.gouv.cnsp.monitorfish.domain.repositories.PriorNotificationUploadRepository +import fr.gouv.cnsp.monitorfish.utils.CustomZonedDateTime + +@UseCase +class CreatePriorNotificationUpload( + private val logbookReportRepository: LogbookReportRepository, + private val manualPriorNotificationRepository: ManualPriorNotificationRepository, + private val priorNotificationUploadRepository: PriorNotificationUploadRepository, +) { + fun execute(identifier: PriorNotificationIdentifier, content: ByteArray, fileName: String, mimeType: String) { + val createdAt = CustomZonedDateTime.now() + + val newPriorNotificationDocument = PriorNotificationDocument( + id = null, + content = content, + createdAt = createdAt, + fileName = fileName, + isManualPriorNotification = identifier.isManualPriorNotification, + mimeType = mimeType, + reportId = identifier.reportId, + updatedAt = createdAt, + ) + + priorNotificationUploadRepository.save(newPriorNotificationDocument) + + if (identifier.isManualPriorNotification) { + manualPriorNotificationRepository.updateState( + reportId = identifier.reportId, + isBeingSent = false, + isSent = false, + isVerified = false, + ) + } else { + logbookReportRepository.updatePriorNotificationState( + reportId = identifier.reportId, + operationDate = identifier.operationDate, + isBeingSent = false, + isSent = false, + isVerified = false, + ) + } + } +} diff --git a/backend/src/main/kotlin/fr/gouv/cnsp/monitorfish/domain/use_cases/prior_notification/DeletePriorNotificationUpload.kt b/backend/src/main/kotlin/fr/gouv/cnsp/monitorfish/domain/use_cases/prior_notification/DeletePriorNotificationUpload.kt new file mode 100644 index 0000000000..408bd50be2 --- /dev/null +++ b/backend/src/main/kotlin/fr/gouv/cnsp/monitorfish/domain/use_cases/prior_notification/DeletePriorNotificationUpload.kt @@ -0,0 +1,35 @@ +package fr.gouv.cnsp.monitorfish.domain.use_cases.prior_notification + +import fr.gouv.cnsp.monitorfish.config.UseCase +import fr.gouv.cnsp.monitorfish.domain.entities.prior_notification.PriorNotificationIdentifier +import fr.gouv.cnsp.monitorfish.domain.repositories.LogbookReportRepository +import fr.gouv.cnsp.monitorfish.domain.repositories.ManualPriorNotificationRepository +import fr.gouv.cnsp.monitorfish.domain.repositories.PriorNotificationUploadRepository + +@UseCase +class DeletePriorNotificationUpload( + private val logbookReportRepository: LogbookReportRepository, + private val manualPriorNotificationRepository: ManualPriorNotificationRepository, + private val priorNotificationUploadRepository: PriorNotificationUploadRepository, +) { + fun execute(identifier: PriorNotificationIdentifier, priorNotificationUploadId: String) { + priorNotificationUploadRepository.deleteById(priorNotificationUploadId) + + if (identifier.isManualPriorNotification) { + manualPriorNotificationRepository.updateState( + reportId = identifier.reportId, + isBeingSent = false, + isSent = false, + isVerified = false, + ) + } else { + logbookReportRepository.updatePriorNotificationState( + reportId = identifier.reportId, + operationDate = identifier.operationDate, + isBeingSent = false, + isSent = false, + isVerified = false, + ) + } + } +} diff --git a/backend/src/main/kotlin/fr/gouv/cnsp/monitorfish/domain/use_cases/prior_notification/GetPriorNotificationUpload.kt b/backend/src/main/kotlin/fr/gouv/cnsp/monitorfish/domain/use_cases/prior_notification/GetPriorNotificationUpload.kt new file mode 100644 index 0000000000..c3b9c80dcc --- /dev/null +++ b/backend/src/main/kotlin/fr/gouv/cnsp/monitorfish/domain/use_cases/prior_notification/GetPriorNotificationUpload.kt @@ -0,0 +1,14 @@ +package fr.gouv.cnsp.monitorfish.domain.use_cases.prior_notification + +import fr.gouv.cnsp.monitorfish.config.UseCase +import fr.gouv.cnsp.monitorfish.domain.entities.prior_notification.PriorNotificationDocument +import fr.gouv.cnsp.monitorfish.domain.repositories.PriorNotificationUploadRepository + +@UseCase +class GetPriorNotificationUpload( + private val priorNotificationUploadRepository: PriorNotificationUploadRepository, +) { + fun execute(priorNotificationUploadId: String): PriorNotificationDocument { + return priorNotificationUploadRepository.findById(priorNotificationUploadId) + } +} diff --git a/backend/src/main/kotlin/fr/gouv/cnsp/monitorfish/domain/use_cases/prior_notification/GetPriorNotificationUploads.kt b/backend/src/main/kotlin/fr/gouv/cnsp/monitorfish/domain/use_cases/prior_notification/GetPriorNotificationUploads.kt new file mode 100644 index 0000000000..988b3e71d5 --- /dev/null +++ b/backend/src/main/kotlin/fr/gouv/cnsp/monitorfish/domain/use_cases/prior_notification/GetPriorNotificationUploads.kt @@ -0,0 +1,14 @@ +package fr.gouv.cnsp.monitorfish.domain.use_cases.prior_notification + +import fr.gouv.cnsp.monitorfish.config.UseCase +import fr.gouv.cnsp.monitorfish.domain.entities.prior_notification.PriorNotificationDocument +import fr.gouv.cnsp.monitorfish.domain.repositories.PriorNotificationUploadRepository + +@UseCase +class GetPriorNotificationUploads( + private val priorNotificationUploadRepository: PriorNotificationUploadRepository, +) { + fun execute(reportId: String): List { + return priorNotificationUploadRepository.findAllByReportId(reportId) + } +} diff --git a/backend/src/main/kotlin/fr/gouv/cnsp/monitorfish/infrastructure/api/bff/PriorNotificationController.kt b/backend/src/main/kotlin/fr/gouv/cnsp/monitorfish/infrastructure/api/bff/PriorNotificationController.kt index 913a88f0f7..b0e394b0ab 100644 --- a/backend/src/main/kotlin/fr/gouv/cnsp/monitorfish/infrastructure/api/bff/PriorNotificationController.kt +++ b/backend/src/main/kotlin/fr/gouv/cnsp/monitorfish/infrastructure/api/bff/PriorNotificationController.kt @@ -1,6 +1,7 @@ package fr.gouv.cnsp.monitorfish.infrastructure.api.bff import fr.gouv.cnsp.monitorfish.domain.entities.facade.SeafrontGroup +import fr.gouv.cnsp.monitorfish.domain.entities.prior_notification.PriorNotificationIdentifier import fr.gouv.cnsp.monitorfish.domain.entities.prior_notification.PriorNotificationState import fr.gouv.cnsp.monitorfish.domain.entities.prior_notification.filters.PriorNotificationsFilter import fr.gouv.cnsp.monitorfish.domain.entities.prior_notification.sorters.PriorNotificationsSortColumn @@ -9,6 +10,8 @@ import fr.gouv.cnsp.monitorfish.infrastructure.api.input.LogbookPriorNotificatio import fr.gouv.cnsp.monitorfish.infrastructure.api.input.ManualPriorNotificationComputeDataInput import fr.gouv.cnsp.monitorfish.infrastructure.api.input.ManualPriorNotificationFormDataInput import fr.gouv.cnsp.monitorfish.infrastructure.api.outputs.* +import fr.gouv.cnsp.monitorfish.infrastructure.exceptions.BackendRequestErrorCode +import fr.gouv.cnsp.monitorfish.infrastructure.exceptions.BackendRequestException import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.Parameter import io.swagger.v3.oas.annotations.tags.Tag @@ -19,6 +22,7 @@ import org.springframework.http.HttpStatus import org.springframework.http.MediaType import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* +import org.springframework.web.multipart.MultipartFile import java.time.ZonedDateTime import java.time.format.DateTimeFormatter.ISO_DATE_TIME @@ -28,14 +32,18 @@ import java.time.format.DateTimeFormatter.ISO_DATE_TIME class PriorNotificationController( private val computeManualPriorNotification: ComputeManualPriorNotification, private val createOrUpdateManualPriorNotification: CreateOrUpdateManualPriorNotification, + private val createPriorNotificationUpload: CreatePriorNotificationUpload, + private val deletePriorNotificationUpload: DeletePriorNotificationUpload, private val getPriorNotification: GetPriorNotification, private val getPriorNotificationPdfDocument: GetPriorNotificationPdfDocument, + private val getPriorNotificationUpload: GetPriorNotificationUpload, + private val getPriorNotificationUploads: GetPriorNotificationUploads, private val getPriorNotifications: GetPriorNotifications, private val getNumberToVerify: GetNumberToVerify, private val getPriorNotificationTypes: GetPriorNotificationTypes, + private val invalidatePriorNotification: InvalidatePriorNotification, private val updateLogbookPriorNotification: UpdateLogbookPriorNotification, private val verifyAndSendPriorNotification: VerifyAndSendPriorNotification, - private val invalidatePriorNotification: InvalidatePriorNotification, ) { data class Status(val status: String) @@ -282,7 +290,7 @@ class PriorNotificationController( } @GetMapping("/{reportId}/pdf") - @Operation(summary = "Get the PDF document") + @Operation(summary = "Get the PNO PDF document") fun getPdfDocument( @PathParam("Logbook message `reportId`") @PathVariable(name = "reportId") @@ -357,4 +365,87 @@ class PriorNotificationController( return PriorNotificationDataOutput.fromPriorNotification(updatedPriorNotification) } + + @GetMapping("/{reportId}/uploads/{priorNotificationUploadId}") + @Operation(summary = "Download a prior notification attachment") + fun getUploads( + @PathParam("Logbook message `reportId`") + @PathVariable(name = "reportId") + reportId: String, + @PathParam("Prior notification upload ID`") + @PathVariable(name = "priorNotificationUploadId") + priorNotificationUploadId: String, + ): ResponseEntity { + val document = getPriorNotificationUpload.execute(priorNotificationUploadId) + + val headers = HttpHeaders().apply { + contentType = MediaType.parseMediaType(document.mimeType) + setContentDispositionFormData("attachment", document.fileName) + } + + return ResponseEntity(document.content, headers, HttpStatus.OK) + } + + @GetMapping("/{reportId}/uploads") + @Operation(summary = "Get all the attachment documents for a given prior notification") + fun getUploads( + @PathParam("Logbook message `reportId`") + @PathVariable(name = "reportId") + reportId: String, + ): List { + return getPriorNotificationUploads.execute(reportId) + .map { PriorNotificationUploadDataOutput.fromPriorNotificationDocument(it) } + } + + @PostMapping("/{reportId}/uploads") + @Operation(summary = "Attach a document to a prior notification") + fun createUpload( + @PathParam("Logbook message `reportId`") + @PathVariable(name = "reportId") + reportId: String, + @Parameter(description = "Operation date (to optimize SQL query via Timescale).") + @RequestParam(name = "operationDate") + operationDate: ZonedDateTime, + @Parameter(description = "Is the prior notification manually created?") + @RequestParam(name = "isManualPriorNotification") + isManualPriorNotification: Boolean, + @RequestParam("file") + file: MultipartFile, + ): ResponseEntity<*> { + val content = file.bytes + ?: throw BackendRequestException(BackendRequestErrorCode.EMPTY_UPLOADED_FILE) + val fileName = file.originalFilename + ?: throw BackendRequestException(BackendRequestErrorCode.MISSING_UPLOADED_FILE_NAME) + val mimeType = file.contentType + ?: throw BackendRequestException(BackendRequestErrorCode.MISSING_UPLOADED_FILE_TYPE) + + val identifier = PriorNotificationIdentifier(reportId, operationDate, isManualPriorNotification) + + createPriorNotificationUpload.execute(identifier, content, fileName, mimeType) + + return ResponseEntity("File uploaded successfully: " + file.originalFilename, HttpStatus.OK) + } + + @DeleteMapping("/{reportId}/uploads/{priorNotificationUploadId}") + @Operation(summary = "Delete a prior notification attachment") + fun deleteUpload( + @PathParam("Logbook message `reportId`") + @PathVariable(name = "reportId") + reportId: String, + @Parameter(description = "Operation date (to optimize SQL query via Timescale).") + @RequestParam(name = "operationDate") + operationDate: ZonedDateTime, + @Parameter(description = "Is the prior notification manually created?") + @RequestParam(name = "isManualPriorNotification") + isManualPriorNotification: Boolean, + @PathParam("Prior notification upload ID") + @PathVariable(name = "priorNotificationUploadId") + priorNotificationUploadId: String, + ): ResponseEntity { + val identifier = PriorNotificationIdentifier(reportId, operationDate, isManualPriorNotification) + + deletePriorNotificationUpload.execute(identifier, priorNotificationUploadId) + + return ResponseEntity(HttpStatus.NO_CONTENT) + } } diff --git a/backend/src/main/kotlin/fr/gouv/cnsp/monitorfish/infrastructure/api/outputs/PriorNotificationUploadDataOutput.kt b/backend/src/main/kotlin/fr/gouv/cnsp/monitorfish/infrastructure/api/outputs/PriorNotificationUploadDataOutput.kt new file mode 100644 index 0000000000..9eaed9446e --- /dev/null +++ b/backend/src/main/kotlin/fr/gouv/cnsp/monitorfish/infrastructure/api/outputs/PriorNotificationUploadDataOutput.kt @@ -0,0 +1,32 @@ +package fr.gouv.cnsp.monitorfish.infrastructure.api.outputs + +import fr.gouv.cnsp.monitorfish.domain.entities.prior_notification.PriorNotificationDocument +import fr.gouv.cnsp.monitorfish.utils.CustomZonedDateTime + +class PriorNotificationUploadDataOutput( + val id: String, + val createdAt: CustomZonedDateTime, + val fileName: String, + val isManualPriorNotification: Boolean, + val mimeType: String, + val reportId: String, + val updatedAt: CustomZonedDateTime, +) { + companion object { + fun fromPriorNotificationDocument(priorNotificationDocument: PriorNotificationDocument): PriorNotificationUploadDataOutput { + val id = requireNotNull(priorNotificationDocument.id) { + "`id` is null." + } + + return PriorNotificationUploadDataOutput( + id = id, + createdAt = priorNotificationDocument.createdAt, + fileName = priorNotificationDocument.fileName, + isManualPriorNotification = priorNotificationDocument.isManualPriorNotification, + mimeType = priorNotificationDocument.mimeType, + reportId = priorNotificationDocument.reportId, + updatedAt = priorNotificationDocument.updatedAt, + ) + } + } +} diff --git a/backend/src/main/kotlin/fr/gouv/cnsp/monitorfish/infrastructure/database/entities/PriorNotificationUploadEntity.kt b/backend/src/main/kotlin/fr/gouv/cnsp/monitorfish/infrastructure/database/entities/PriorNotificationUploadEntity.kt new file mode 100644 index 0000000000..a41a5e30cd --- /dev/null +++ b/backend/src/main/kotlin/fr/gouv/cnsp/monitorfish/infrastructure/database/entities/PriorNotificationUploadEntity.kt @@ -0,0 +1,98 @@ +package fr.gouv.cnsp.monitorfish.infrastructure.database.entities + +import fr.gouv.cnsp.monitorfish.domain.entities.prior_notification.PriorNotificationDocument +import fr.gouv.cnsp.monitorfish.utils.CustomZonedDateTime +import jakarta.persistence.* +import org.hibernate.annotations.JdbcType +import org.hibernate.type.descriptor.jdbc.BinaryJdbcType +import java.time.ZonedDateTime + +@Entity +@Table(name = "prior_notification_uploads") +data class PriorNotificationUploadEntity( + @Id + @Column(name = "id", updatable = false) + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: String?, + + @Column(name = "content", nullable = false) + @JdbcType(BinaryJdbcType::class) + val content: ByteArray, + + @Column(name = "created_at", nullable = false) + val createdAt: ZonedDateTime, + + @Column(name = "file_name", nullable = false) + val fileName: String, + + @Column(name = "is_manual_prior_notification", nullable = false) + val isManualPriorNotification: Boolean, + + @Column(name = "mime_type", nullable = false) + val mimeType: String, + + @Column(name = "report_id", nullable = false) + val reportId: String, + + @Column(name = "updated_at", nullable = false) + val updatedAt: ZonedDateTime, +) { + + fun toDocument(): PriorNotificationDocument { + return PriorNotificationDocument( + id = id!!, + content = content, + createdAt = CustomZonedDateTime.fromZonedDateTime(createdAt), + fileName = fileName, + isManualPriorNotification = isManualPriorNotification, + mimeType = mimeType, + reportId = reportId, + updatedAt = CustomZonedDateTime.fromZonedDateTime(updatedAt), + ) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as PriorNotificationUploadEntity + + if (id != other.id) return false + if (!content.contentEquals(other.content)) return false + if (createdAt != other.createdAt) return false + if (fileName != other.fileName) return false + if (isManualPriorNotification != other.isManualPriorNotification) return false + if (mimeType != other.mimeType) return false + if (reportId != other.reportId) return false + if (updatedAt != other.updatedAt) return false + + return true + } + + override fun hashCode(): Int { + var result = id?.hashCode() ?: 0 + result = 31 * result + content.contentHashCode() + result = 31 * result + createdAt.hashCode() + result = 31 * result + fileName.hashCode() + result = 31 * result + isManualPriorNotification.hashCode() + result = 31 * result + mimeType.hashCode() + result = 31 * result + reportId.hashCode() + result = 31 * result + updatedAt.hashCode() + return result + } + + companion object { + fun fromDocument(priorNotificationDocument: PriorNotificationDocument): PriorNotificationUploadEntity { + return PriorNotificationUploadEntity( + id = priorNotificationDocument.id, + content = priorNotificationDocument.content, + createdAt = priorNotificationDocument.createdAt.toZonedDateTime(), + fileName = priorNotificationDocument.fileName, + isManualPriorNotification = priorNotificationDocument.isManualPriorNotification, + mimeType = priorNotificationDocument.mimeType, + reportId = priorNotificationDocument.reportId, + updatedAt = priorNotificationDocument.updatedAt.toZonedDateTime(), + ) + } + } +} diff --git a/backend/src/main/kotlin/fr/gouv/cnsp/monitorfish/infrastructure/database/repositories/JpaPriorNotificationUploadRepository.kt b/backend/src/main/kotlin/fr/gouv/cnsp/monitorfish/infrastructure/database/repositories/JpaPriorNotificationUploadRepository.kt new file mode 100644 index 0000000000..93017484c4 --- /dev/null +++ b/backend/src/main/kotlin/fr/gouv/cnsp/monitorfish/infrastructure/database/repositories/JpaPriorNotificationUploadRepository.kt @@ -0,0 +1,38 @@ +package fr.gouv.cnsp.monitorfish.infrastructure.database.repositories + +import fr.gouv.cnsp.monitorfish.domain.entities.prior_notification.PriorNotificationDocument +import fr.gouv.cnsp.monitorfish.domain.exceptions.BackendUsageErrorCode +import fr.gouv.cnsp.monitorfish.domain.exceptions.BackendUsageException +import fr.gouv.cnsp.monitorfish.domain.repositories.PriorNotificationUploadRepository +import fr.gouv.cnsp.monitorfish.infrastructure.database.entities.PriorNotificationUploadEntity +import fr.gouv.cnsp.monitorfish.infrastructure.database.repositories.interfaces.DBPriorNotificationUploadRepository +import org.springframework.stereotype.Repository +import org.springframework.transaction.annotation.Transactional + +@Repository +class JpaPriorNotificationUploadRepository( + private val dbPriorNotificationUploadRepository: DBPriorNotificationUploadRepository, +) : PriorNotificationUploadRepository { + @Transactional + override fun deleteById(id: String) { + dbPriorNotificationUploadRepository.deleteById(id) + } + + override fun findAllByReportId(reportId: String): List { + return dbPriorNotificationUploadRepository.findByReportId(reportId).map { it.toDocument() } + } + + override fun findById(id: String): PriorNotificationDocument { + try { + return dbPriorNotificationUploadRepository.findById(id).get().toDocument() + } catch (e: Exception) { + throw BackendUsageException(BackendUsageErrorCode.NOT_FOUND) + } + } + + @Transactional + override fun save(newPriorNotificationDocument: PriorNotificationDocument): PriorNotificationDocument { + return dbPriorNotificationUploadRepository + .save(PriorNotificationUploadEntity.fromDocument(newPriorNotificationDocument)).toDocument() + } +} diff --git a/backend/src/main/kotlin/fr/gouv/cnsp/monitorfish/infrastructure/database/repositories/interfaces/DBPriorNotificationUploadRepository.kt b/backend/src/main/kotlin/fr/gouv/cnsp/monitorfish/infrastructure/database/repositories/interfaces/DBPriorNotificationUploadRepository.kt new file mode 100644 index 0000000000..dac73f2374 --- /dev/null +++ b/backend/src/main/kotlin/fr/gouv/cnsp/monitorfish/infrastructure/database/repositories/interfaces/DBPriorNotificationUploadRepository.kt @@ -0,0 +1,19 @@ +package fr.gouv.cnsp.monitorfish.infrastructure.database.repositories.interfaces + +import fr.gouv.cnsp.monitorfish.infrastructure.database.entities.PriorNotificationUploadEntity +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query + +interface DBPriorNotificationUploadRepository : JpaRepository { + @Query( + """ + SELECT * + FROM prior_notification_uploads + WHERE report_id = :reportId + """, + nativeQuery = true, + ) + fun findByReportId(reportId: String): List + + fun save(entity: PriorNotificationUploadEntity): PriorNotificationUploadEntity +} diff --git a/backend/src/main/kotlin/fr/gouv/cnsp/monitorfish/infrastructure/exceptions/BackendRequestErrorCode.kt b/backend/src/main/kotlin/fr/gouv/cnsp/monitorfish/infrastructure/exceptions/BackendRequestErrorCode.kt index 4ac3cec475..8ea5e93243 100644 --- a/backend/src/main/kotlin/fr/gouv/cnsp/monitorfish/infrastructure/exceptions/BackendRequestErrorCode.kt +++ b/backend/src/main/kotlin/fr/gouv/cnsp/monitorfish/infrastructure/exceptions/BackendRequestErrorCode.kt @@ -12,5 +12,12 @@ package fr.gouv.cnsp.monitorfish.infrastructure.exceptions * **Don't forget to mirror any update here in the corresponding Frontend enum.** */ enum class BackendRequestErrorCode { - WRONG_BODY_PARAMETER_TYPE, + /** The uploaded file is empty. */ + EMPTY_UPLOADED_FILE, + + /** Couldn't extract the uploaded file name. */ + MISSING_UPLOADED_FILE_NAME, + + /** Couldn't extract the uploaded file type. */ + MISSING_UPLOADED_FILE_TYPE, } diff --git a/backend/src/main/resources/db/migration/internal/V0.278__Create_prior_niotification_uploads_table.sql b/backend/src/main/resources/db/migration/internal/V0.278__Create_prior_niotification_uploads_table.sql new file mode 100644 index 0000000000..e2d708514a --- /dev/null +++ b/backend/src/main/resources/db/migration/internal/V0.278__Create_prior_niotification_uploads_table.sql @@ -0,0 +1,11 @@ +CREATE TABLE prior_notification_uploads ( + id VARCHAR(36) PRIMARY KEY DEFAULT gen_random_uuid(), + report_id VARCHAR(255) NOT NULL, + is_manual_prior_notification BOOLEAN NOT NULL, + file_name VARCHAR(255) NOT NULL, + mime_type VARCHAR(255) NOT NULL, + content BYTEA NOT NULL, + + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL +); diff --git a/backend/src/test/kotlin/fr/gouv/cnsp/monitorfish/infrastructure/api/bff/PriorNotificationControllerUTests.kt b/backend/src/test/kotlin/fr/gouv/cnsp/monitorfish/infrastructure/api/bff/PriorNotificationControllerUTests.kt index 16183538fb..b5b42c3710 100644 --- a/backend/src/test/kotlin/fr/gouv/cnsp/monitorfish/infrastructure/api/bff/PriorNotificationControllerUTests.kt +++ b/backend/src/test/kotlin/fr/gouv/cnsp/monitorfish/infrastructure/api/bff/PriorNotificationControllerUTests.kt @@ -43,12 +43,24 @@ class PriorNotificationControllerUTests { @MockBean private lateinit var createOrUpdateManualPriorNotification: CreateOrUpdateManualPriorNotification + @MockBean + private lateinit var createPriorNotificationUpload: CreatePriorNotificationUpload + + @MockBean + private lateinit var deletePriorNotificationUpload: DeletePriorNotificationUpload + @MockBean private lateinit var getPriorNotification: GetPriorNotification @MockBean private lateinit var getPriorNotificationPdfDocument: GetPriorNotificationPdfDocument + @MockBean + private lateinit var getPriorNotificationUpload: GetPriorNotificationUpload + + @MockBean + private lateinit var getPriorNotificationUploads: GetPriorNotificationUploads + @MockBean private lateinit var getPriorNotifications: GetPriorNotifications diff --git a/backend/src/test/kotlin/fr/gouv/cnsp/monitorfish/infrastructure/database/repositories/JpaPriorNotificationPdfDocumentRepositoryITests.kt b/backend/src/test/kotlin/fr/gouv/cnsp/monitorfish/infrastructure/database/repositories/JpaPriorNotificationPdfDocumentRepositoryITests.kt index 70b15711ca..632ad2b5ba 100644 --- a/backend/src/test/kotlin/fr/gouv/cnsp/monitorfish/infrastructure/database/repositories/JpaPriorNotificationPdfDocumentRepositoryITests.kt +++ b/backend/src/test/kotlin/fr/gouv/cnsp/monitorfish/infrastructure/database/repositories/JpaPriorNotificationPdfDocumentRepositoryITests.kt @@ -16,12 +16,15 @@ class JpaPriorNotificationPdfDocumentRepositoryITests : AbstractDBTests() { @Test @Transactional - fun `findByReportId Should return a pdf document`() { + fun `findByReportId Should return the expected PDF document`() { + // Given + val reportId = "FAKE_OPERATION_102" + // When - val pdfDocument = jpaPriorNotificationPdfDocumentRepository.findByReportId("FAKE_OPERATION_102") + val pdfDocument = jpaPriorNotificationPdfDocumentRepository.findByReportId(reportId) // Then - assertThat(pdfDocument.reportId).isEqualTo("FAKE_OPERATION_102") + assertThat(pdfDocument.reportId).isEqualTo(reportId) assertThat(pdfDocument.source).isEqualTo(PriorNotificationSource.LOGBOOK) assertThat(pdfDocument.generationDatetimeUtc).isEqualTo(ZonedDateTime.parse("2024-07-03T14:45:00Z")) assertThat(pdfDocument.pdfDocument).isNotNull() @@ -29,21 +32,17 @@ class JpaPriorNotificationPdfDocumentRepositoryITests : AbstractDBTests() { @Test @Transactional - fun `deleteByReportId Should delete a pdf document`() { + fun `deleteByReportId Should delete the expected PDF document`() { // Given - val existingPdfDocument = jpaPriorNotificationPdfDocumentRepository.findByReportId("FAKE_OPERATION_102") - assertThat(existingPdfDocument.reportId).isEqualTo("FAKE_OPERATION_102") + val reportId = "FAKE_OPERATION_102" // When - jpaPriorNotificationPdfDocumentRepository.deleteByReportId("FAKE_OPERATION_102") - - // Then - val throwable = - catchThrowable { - jpaPriorNotificationPdfDocumentRepository.findByReportId("FAKE_OPERATION_102") - } + jpaPriorNotificationPdfDocumentRepository.deleteByReportId(reportId) // Then + val throwable = catchThrowable { + jpaPriorNotificationPdfDocumentRepository.findByReportId(reportId) + } assertThat(throwable).isNotNull() assertThat((throwable as BackendUsageException).code).isEqualTo(BackendUsageErrorCode.NOT_FOUND) } diff --git a/backend/src/test/kotlin/fr/gouv/cnsp/monitorfish/infrastructure/database/repositories/JpaPriorNotificationUploadRepositoryITests.kt b/backend/src/test/kotlin/fr/gouv/cnsp/monitorfish/infrastructure/database/repositories/JpaPriorNotificationUploadRepositoryITests.kt new file mode 100644 index 0000000000..36a9fe5e29 --- /dev/null +++ b/backend/src/test/kotlin/fr/gouv/cnsp/monitorfish/infrastructure/database/repositories/JpaPriorNotificationUploadRepositoryITests.kt @@ -0,0 +1,125 @@ +package fr.gouv.cnsp.monitorfish.infrastructure.database.repositories + +import fr.gouv.cnsp.monitorfish.domain.entities.prior_notification.PriorNotificationDocument +import fr.gouv.cnsp.monitorfish.domain.exceptions.BackendUsageErrorCode +import fr.gouv.cnsp.monitorfish.domain.exceptions.BackendUsageException +import fr.gouv.cnsp.monitorfish.utils.CustomZonedDateTime +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.catchThrowable +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.transaction.annotation.Transactional + +class JpaPriorNotificationUploadRepositoryITests : AbstractDBTests() { + @Autowired + private lateinit var jpaPriorNotificationUploadRepository: JpaPriorNotificationUploadRepository + + private val firstFakePriorNotificationDocument = PriorNotificationDocument( + id = null, + content = "%PDF-1.5\n".toByteArray(), + createdAt = CustomZonedDateTime.now(), + fileName = "fake_file_1.pdf", + isManualPriorNotification = true, + mimeType = "application/pdf", + reportId = "00000000-0000-4000-0000-000000000011", + updatedAt = CustomZonedDateTime.now(), + ) + private val secondFakePriorNotificationDocument = PriorNotificationDocument( + id = null, + content = "%PDF-1.5\n".toByteArray(), + createdAt = CustomZonedDateTime.now(), + fileName = "fake_file_2.pdf", + isManualPriorNotification = true, + mimeType = "application/pdf", + reportId = "00000000-0000-4000-0000-000000000011", + updatedAt = CustomZonedDateTime.now(), + ) + private val thirdFakePriorNotificationDocument = PriorNotificationDocument( + id = null, + content = "%PDF-1.5\n".toByteArray(), + createdAt = CustomZonedDateTime.now(), + fileName = "fake_file_3.pdf", + isManualPriorNotification = true, + mimeType = "application/pdf", + reportId = "00000000-0000-4000-0000-000000000012", + updatedAt = CustomZonedDateTime.now(), + ) + + private lateinit var firstFakePriorNotificationDocumentId: String + private lateinit var secondFakePriorNotificationDocumentId: String + private lateinit var thirdFakePriorNotificationDocumentId: String + + @BeforeEach + fun beforeEach() { + var newDocument = jpaPriorNotificationUploadRepository.save(firstFakePriorNotificationDocument) + firstFakePriorNotificationDocumentId = newDocument.id!! + newDocument = jpaPriorNotificationUploadRepository.save(secondFakePriorNotificationDocument) + secondFakePriorNotificationDocumentId = newDocument.id!! + newDocument = jpaPriorNotificationUploadRepository.save(thirdFakePriorNotificationDocument) + thirdFakePriorNotificationDocumentId = newDocument.id!! + } + + @Test + @Transactional + fun `deleteById Should delete the expected document`() { + // When + jpaPriorNotificationUploadRepository.deleteById(firstFakePriorNotificationDocument.reportId) + + // Then + val throwable = catchThrowable { + jpaPriorNotificationUploadRepository.findById(firstFakePriorNotificationDocument.reportId) + } + assertThat(throwable).isNotNull() + assertThat((throwable as BackendUsageException).code).isEqualTo(BackendUsageErrorCode.NOT_FOUND) + } + + @Test + @Transactional + fun `findAllByReportId Should return the expected documents`() { + // Given + val reportId = "00000000-0000-4000-0000-000000000011" + + // When + val result = jpaPriorNotificationUploadRepository.findAllByReportId(reportId) + + // Then + assertThat(result.all { it.reportId == reportId }).isTrue() + } + + @Test + @Transactional + fun `findById Should return the expected document`() { + // When + val result = jpaPriorNotificationUploadRepository.findById(firstFakePriorNotificationDocumentId) + + // Then + assertThat(result.id).isEqualTo(firstFakePriorNotificationDocumentId) + assertThat(result.reportId).isEqualTo(firstFakePriorNotificationDocument.reportId) + } + + @Test + @Transactional + fun `save Should return the expected document`() { + // Given + val newDocument = PriorNotificationDocument( + id = null, + content = "%PDF-1.5\n".toByteArray(), + createdAt = CustomZonedDateTime.now(), + fileName = "fake_file.pdf", + isManualPriorNotification = true, + mimeType = "application/pdf", + reportId = "00000000-0000-4000-0000-000000000013", + updatedAt = CustomZonedDateTime.now(), + ) + + // When + jpaPriorNotificationUploadRepository.save(newDocument) + + // Then + val result = jpaPriorNotificationUploadRepository.findAllByReportId(newDocument.reportId) + assertThat(result).hasSize(1) + assertThat(result[0].content.toString()).isEqualTo(newDocument.content.toString()) + assertThat(result[0].reportId).isEqualTo(newDocument.reportId) + } +} diff --git a/frontend/cypress/e2e/side_window/logbook_prior_notification_form/behavior.spec.ts b/frontend/cypress/e2e/side_window/logbook_prior_notification_form/behavior.spec.ts index 90338af917..e615768cb9 100644 --- a/frontend/cypress/e2e/side_window/logbook_prior_notification_form/behavior.spec.ts +++ b/frontend/cypress/e2e/side_window/logbook_prior_notification_form/behavior.spec.ts @@ -1,3 +1,5 @@ +import dayjs from 'dayjs' + import { editSideWindowPriorNotification } from './utils' context('Side Window > Logbook Prior Notification Form > Behavior', () => { @@ -12,4 +14,27 @@ context('Side Window > Logbook Prior Notification Form > Behavior', () => { cy.contains('button', 'Télécharger').should('be.disabled') cy.contains('button', 'Diffuser').should('be.disabled') }) + + it('Should not update the logbook prior notification before the form is filled', () => { + cy.intercept('PUT', '/bff/v1/prior_notifications/logbook/FAKE_OPERATION_116*', cy.spy().as('updateForm')) + + editSideWindowPriorNotification(`LE MARIN`, 'FAKE_OPERATION_116') + + cy.contains(`LE MARIN D'EAU DOUCE (CFR111)`).should('be.visible') + + cy.get('@updateForm').should('not.have.been.called') + + cy.fill("Points d'attention identifiés par le CNSP", 'Une note') + + cy.get('@updateForm').should('have.been.calledOnce') + + // Reset + const operationDate = dayjs().subtract(6, 'hours').toISOString() + cy.request('PUT', `/bff/v1/prior_notifications/logbook/FAKE_OPERATION_116?operationDate=${operationDate}`, { + body: { + authorTrigram: null, + note: null + } + }) + }) }) diff --git a/frontend/cypress/e2e/side_window/logbook_prior_notification_form/form.spec.ts b/frontend/cypress/e2e/side_window/logbook_prior_notification_form/form.spec.ts index fcdf264580..b784381b7f 100644 --- a/frontend/cypress/e2e/side_window/logbook_prior_notification_form/form.spec.ts +++ b/frontend/cypress/e2e/side_window/logbook_prior_notification_form/form.spec.ts @@ -49,26 +49,43 @@ context('Side Window > Logbook Prior Notification Form > Form', () => { }) }) - it('Should not update the logbook prior notification before the form is filled', () => { - cy.intercept('PUT', '/bff/v1/prior_notifications/logbook/FAKE_OPERATION_116*', cy.spy().as('updateForm')) + it('Should attach and remove a document to logbook prior notification', () => { + cy.intercept('GET', `/bff/v1/prior_notifications/FAKE_OPERATION_115/uploads`).as('getUploads') + cy.intercept( + 'POST', + `/bff/v1/prior_notifications/FAKE_OPERATION_115/uploads?isManualPriorNotification=false&operationDate=*` + ).as('uploadDocument') + cy.intercept('DELETE', `/bff/v1/prior_notifications/FAKE_OPERATION_115/uploads/*`).as('deleteDocument') - editSideWindowPriorNotification(`LE MARIN`, 'FAKE_OPERATION_116') + editSideWindowPriorNotification(`MER À BOIRE`, 'FAKE_OPERATION_115') - cy.contains(`LE MARIN D'EAU DOUCE (CFR111)`).should('be.visible') + cy.wait('@getUploads') - cy.get('@updateForm').should('not.have.been.called') + cy.fixture('sample.pdf').then(fileContent => { + cy.get('input[type=file]').selectFile( + { + contents: Cypress.Buffer.from(fileContent), + fileName: 'sample.pdf', + mimeType: 'application/pdf' + }, + { + action: 'select', + force: true + } + ) - cy.fill("Points d'attention identifiés par le CNSP", 'Une note') + cy.wait('@uploadDocument').then(() => { + cy.wait('@getUploads').wait(500) - cy.get('@updateForm').should('have.been.calledOnce') + cy.contains('.rs-uploader-file-item', 'sample.pdf') + .find('.rs-uploader-file-item-btn-remove .rs-icon') + .forceClick() - // Reset - const operationDate = dayjs().subtract(6, 'hours').toISOString() - cy.request('PUT', `/bff/v1/prior_notifications/logbook/FAKE_OPERATION_116?operationDate=${operationDate}`, { - body: { - authorTrigram: null, - note: null - } + cy.wait('@deleteDocument') + cy.wait('@getUploads').wait(500) + + cy.contains('sample.pdf').should('not.exist') + }) }) }) diff --git a/frontend/cypress/fixtures/sample.pdf b/frontend/cypress/fixtures/sample.pdf new file mode 100644 index 0000000000..c01805e89c Binary files /dev/null and b/frontend/cypress/fixtures/sample.pdf differ diff --git a/frontend/src/api/api.ts b/frontend/src/api/api.ts index 9e292c5da0..aee344fc8e 100644 --- a/frontend/src/api/api.ts +++ b/frontend/src/api/api.ts @@ -63,10 +63,10 @@ export const monitorenvApi = createApi({ const AUTHORIZATION_HEADER = 'authorization' const CORRELATION_HEADER = 'X-Correlation-Id' +const { IS_OIDC_ENABLED } = getOIDCConfig() const setAuthorizationHeader = async headers => { const user = getOIDCUser() - const { IS_OIDC_ENABLED } = getOIDCConfig() const token = user?.access_token // If we have a token set in state, we pass it. @@ -191,7 +191,6 @@ export const monitorfishApiKy = ky.extend({ hooks: { beforeRequest: [ async request => { - const { IS_OIDC_ENABLED } = getOIDCConfig() const user = getOIDCUser() const token = user?.access_token @@ -209,7 +208,6 @@ export const monitorfishApiKy = ky.extend({ ], beforeRetry: [ async ({ request }) => { - const { IS_OIDC_ENABLED } = getOIDCConfig() const user = getOIDCUser() const token = user?.access_token diff --git a/frontend/src/api/constants.ts b/frontend/src/api/constants.ts index 714c7a3ff5..85b0fe785f 100644 --- a/frontend/src/api/constants.ts +++ b/frontend/src/api/constants.ts @@ -36,6 +36,7 @@ export enum HttpStatusCode { export enum RtkCacheTagType { PriorNotification = 'PriorNotification', + PriorNotificationDocuments = 'PriorNotificationDocuments', PriorNotificationTypes = 'PriorNotificationTypes', PriorNotifications = 'PriorNotifications', PriorNotificationsToVerify = 'PriorNotificationsToVerify', diff --git a/frontend/src/auth/hooks/useAuthRequestHeaders.ts b/frontend/src/auth/hooks/useAuthRequestHeaders.ts new file mode 100644 index 0000000000..2aa0a47440 --- /dev/null +++ b/frontend/src/auth/hooks/useAuthRequestHeaders.ts @@ -0,0 +1,46 @@ +import { sha256 } from '@utils/sha256' +import { getOIDCConfig } from 'auth/getOIDCConfig' +import { getOIDCUser } from 'auth/getOIDCUser' +import { useCallback, useEffect, useState } from 'react' + +const { IS_OIDC_ENABLED } = getOIDCConfig() + +/** + * Hook to get API request headers required for OIDC authentication. + * + * If the returned headers are `undefined`, + * it means that you need to wait for the headers to be updated before making your request. + * This is because the headers are updated asynchronously (`await sha256(nextToken)`). + */ +export function useAuthRequestHeaders(): Record | undefined { + const [headers, setHeaders] = useState | undefined>(undefined) + + const user = getOIDCUser() + const token = user?.access_token + + const updateHeaders = useCallback(async (nextToken: string | undefined) => { + if (!IS_OIDC_ENABLED) { + setHeaders({}) + + return + } + if (!nextToken) { + setHeaders(undefined) + + return + } + + const nextHeaders = { + authorization: `Bearer ${nextToken}`, + ...(crypto?.subtle ? { 'x-correlation-id': await sha256(nextToken) } : {}) + } + + setHeaders(nextHeaders) + }, []) + + useEffect(() => { + updateHeaders(token) + }, [token, updateHeaders]) + + return headers +} diff --git a/frontend/src/features/PriorNotification/PriorNotification.types.ts b/frontend/src/features/PriorNotification/PriorNotification.types.ts index fa238aa8c3..126c0073b3 100644 --- a/frontend/src/features/PriorNotification/PriorNotification.types.ts +++ b/frontend/src/features/PriorNotification/PriorNotification.types.ts @@ -159,6 +159,16 @@ export namespace PriorNotification { name: string } + export type Upload = { + createdAt: string + fileName: string + id: string + isManualPriorNotification: boolean + mimeType: string + reportId: string + updatedAt: string + } + export enum PurposeCode { ACS = 'ACS', ECY = 'ECY', diff --git a/frontend/src/features/PriorNotification/components/LogbookPriorNotificationForm/Form.tsx b/frontend/src/features/PriorNotification/components/LogbookPriorNotificationForm/Form.tsx index a0d8b0471e..fcc57ec1cc 100644 --- a/frontend/src/features/PriorNotification/components/LogbookPriorNotificationForm/Form.tsx +++ b/frontend/src/features/PriorNotification/components/LogbookPriorNotificationForm/Form.tsx @@ -14,6 +14,8 @@ import { useCallback, useMemo, useState } from 'react' import styled from 'styled-components' import { useDebouncedCallback } from 'use-debounce' +import { UploadFiles } from '../shared/UploadFiles' + import type { PriorNotification } from '@features/PriorNotification/PriorNotification.types' type FormProps = Readonly<{ @@ -76,6 +78,14 @@ export function Form({ detail, initialFormValues }: FormProps) { {isSuperUser && } + +
+ + diff --git a/frontend/src/features/PriorNotification/components/shared/DownloadButton/index.tsx b/frontend/src/features/PriorNotification/components/shared/DownloadButton/index.tsx index 56cf8875e0..fe89d7602f 100644 --- a/frontend/src/features/PriorNotification/components/shared/DownloadButton/index.tsx +++ b/frontend/src/features/PriorNotification/components/shared/DownloadButton/index.tsx @@ -8,7 +8,7 @@ import { } from '@features/PriorNotification/priorNotificationApi' import { customSentry, CustomSentryMeasurementName } from '@libs/customSentry' import { Accent, Button, customDayjs, Dropdown, Icon, usePrevious } from '@mtes-mct/monitor-ui' -import { downloadAsPdf } from '@utils/downloadAsPdf' +import { downloadFile } from '@utils/downloadFile' import printJS from 'print-js' import { useEffect, useMemo } from 'react' @@ -74,9 +74,9 @@ export function DownloadButton({ const response = await monitorfishApiKy.get(url) const blob = await response.blob() const generationDate = response.headers.get('x-generation-date') - const fileName = `preavis_debarquement_${generationDate}` + const fileName = `preavis_debarquement_${generationDate}.pdf` - downloadAsPdf(fileName, blob) + downloadFile(fileName, 'application/pdf', blob) } useEffect(() => { diff --git a/frontend/src/features/PriorNotification/components/shared/UploadFiles.tsx b/frontend/src/features/PriorNotification/components/shared/UploadFiles.tsx new file mode 100644 index 0000000000..ff91b0bde6 --- /dev/null +++ b/frontend/src/features/PriorNotification/components/shared/UploadFiles.tsx @@ -0,0 +1,120 @@ +import { monitorfishApiKy } from '@api/api' +import { RtkCacheTagType } from '@api/constants' +import { + priorNotificationApi, + useGetPriorNotificationUploadsQuery +} from '@features/PriorNotification/priorNotificationApi' +import { useMainAppDispatch } from '@hooks/useMainAppDispatch' +import { useKey } from '@mtes-mct/monitor-ui' +import { assertNotNullish } from '@utils/assertNotNullish' +import { downloadFile } from '@utils/downloadFile' +import { useAuthRequestHeaders } from 'auth/hooks/useAuthRequestHeaders' +import { useCallback, useMemo } from 'react' +import { Uploader } from 'rsuite' +import styled from 'styled-components' + +import type { FileType } from 'rsuite/esm/Uploader' + +type UploadFilesProps = Readonly<{ + isManualPriorNotification: boolean + operationDate: string + reportId: string +}> +export function UploadFiles({ isManualPriorNotification, operationDate, reportId }: UploadFilesProps) { + const dispatch = useMainAppDispatch() + const headers = useAuthRequestHeaders() + + const action = `/bff/v1/prior_notifications/${reportId}/uploads?isManualPriorNotification=${isManualPriorNotification}&operationDate=${operationDate}` + const { data: uploads } = useGetPriorNotificationUploadsQuery(reportId) + + const key = useKey([uploads]) + const uploadAsFileTypes: FileType[] | undefined = useMemo( + () => + uploads + ? uploads.map(upload => ({ + fileKey: upload.id, + mimeType: upload.mimeType, + name: upload.fileName + })) + : undefined, + [uploads] + ) + + const download = useCallback( + async (fileType: FileType) => { + assertNotNullish(fileType.fileKey) + assertNotNullish(fileType.name) + + const url = `/bff/v1/prior_notifications/${reportId}/uploads/${fileType.fileKey}` + const response = await monitorfishApiKy.get(url) + const blob = await response.blob() + + downloadFile(fileType.name, (fileType as any).mimeType, blob) + }, + [reportId] + ) + + const refetch = useCallback(() => { + dispatch(priorNotificationApi.util.invalidateTags([RtkCacheTagType.PriorNotificationDocuments])) + }, [dispatch]) + + const remove = useCallback( + async (file: FileType) => { + assertNotNullish(file.fileKey) + + await dispatch( + priorNotificationApi.endpoints.deletePriorNotificationUpload.initiate({ + isManualPriorNotification, + operationDate, + priorNotificationUploadId: String(file.fileKey), + reportId + }) + ).unwrap() + }, + [dispatch, isManualPriorNotification, operationDate, reportId] + ) + + if (!headers || !uploadAsFileTypes) { + return <> + } + + return ( + + + Glissez ou déposez des fichier à ajouter au préavis. + + + ) +} + +const Wrapper = styled.div` + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 8px; + + > .rs-uploader { + > .rs-uploader-file-items { + > .rs-uploader-file-item { + > .rs-uploader-file-item-panel { + cursor: pointer; + } + } + } + } +` + +const File = styled.div` + font-style: italic; + color: ${p => p.theme.color.slateGray}; + padding: 24px 90px; +` diff --git a/frontend/src/features/PriorNotification/priorNotificationApi.ts b/frontend/src/features/PriorNotification/priorNotificationApi.ts index 03e6ecbfd8..5223d833ce 100644 --- a/frontend/src/features/PriorNotification/priorNotificationApi.ts +++ b/frontend/src/features/PriorNotification/priorNotificationApi.ts @@ -13,6 +13,9 @@ import type { LogbookMessage } from '@features/Logbook/LogbookMessage.types' const COMPUTE_PRIOR_NOTIFICATION_ERROR_MESSAGE = "Nous n'avons pas pu calculer note de risque, segments ou types pour ce préavis." const CREATE_PRIOR_NOTIFICATION_ERROR_MESSAGE = "Nous n'avons pas pu créé le préavis." +const DELETE_PRIOR_NOTIFICATION_UPLOAD_ERROR_MESSAGE = "Nous n'avons pas pu supprimer ce document attaché." +const GET_PRIOR_NOTIFICATION_UPLOADS_ERROR_MESSAGE = + "Nous n'avons pas pu récupérer les documents attachés à ce préavis." const UPDATE_PRIOR_NOTIFICATION_ERROR_MESSAGE = "Nous n'avons pas pu modifier le préavis." const GET_PRIOR_NOTIFICATION_DETAIL_ERROR_MESSAGE = "Nous n'avons pas pu récupérer le préavis." const GET_PRIOR_NOTIFICATIONS_ERROR_MESSAGE = "Nous n'avons pas pu récupérer la liste des préavis." @@ -54,6 +57,26 @@ export const priorNotificationApi = monitorfishApi.injectEndpoints({ transformErrorResponse: response => new FrontendApiError(CREATE_PRIOR_NOTIFICATION_ERROR_MESSAGE, response) }), + deletePriorNotificationUpload: builder.mutation< + void, + { + isManualPriorNotification: boolean + operationDate: string + priorNotificationUploadId: string + reportId: string + } + >({ + invalidatesTags: [{ type: RtkCacheTagType.PriorNotificationDocuments }], + query: ({ isManualPriorNotification, operationDate, priorNotificationUploadId, reportId }) => ({ + method: 'DELETE', + url: getUrlOrPathWithQueryParams(`/prior_notifications/${reportId}/uploads/${priorNotificationUploadId}`, { + isManualPriorNotification, + operationDate + }) + }), + transformErrorResponse: response => new FrontendApiError(DELETE_PRIOR_NOTIFICATION_UPLOAD_ERROR_MESSAGE, response) + }), + getPriorNotificationDetail: builder.query< PriorNotification.Detail, PriorNotification.Identifier & { @@ -117,6 +140,12 @@ export const priorNotificationApi = monitorfishApi.injectEndpoints({ transformErrorResponse: response => new FrontendApiError(GET_PRIOR_NOTIFICATION_TYPES_ERROR_MESSAGE, response) }), + getPriorNotificationUploads: builder.query({ + providesTags: () => [{ type: RtkCacheTagType.PriorNotificationDocuments }], + query: reportId => `/prior_notifications/${reportId}/uploads`, + transformErrorResponse: response => new FrontendApiError(GET_PRIOR_NOTIFICATION_UPLOADS_ERROR_MESSAGE, response) + }), + invalidatePriorNotification: builder.mutation< PriorNotification.Detail, PriorNotification.Identifier & { @@ -203,5 +232,6 @@ export const { useGetPriorNotificationPdfExistenceQuery, useGetPriorNotificationsQuery, useGetPriorNotificationsToVerifyQuery, - useGetPriorNotificationTypesQuery + useGetPriorNotificationTypesQuery, + useGetPriorNotificationUploadsQuery } = priorNotificationApi diff --git a/frontend/src/utils/downloadAsPdf.ts b/frontend/src/utils/downloadAsPdf.ts deleted file mode 100644 index fa3b9fb3a0..0000000000 --- a/frontend/src/utils/downloadAsPdf.ts +++ /dev/null @@ -1,7 +0,0 @@ -export function downloadAsPdf(fileName: string, blob: Blob) { - const link = document.createElement('a') - link.href = URL.createObjectURL(new Blob([blob], { type: 'application/pdf' })) - link.download = `${fileName}.pdf` - - link.click() -} diff --git a/frontend/src/utils/downloadFile.ts b/frontend/src/utils/downloadFile.ts new file mode 100644 index 0000000000..4e8e2aaa67 --- /dev/null +++ b/frontend/src/utils/downloadFile.ts @@ -0,0 +1,7 @@ +export function downloadFile(fileName: string, mimeType: string, blob: Blob) { + const link = document.createElement('a') + link.href = URL.createObjectURL(new Blob([blob], { type: mimeType })) + link.download = fileName + + link.click() +}