package com.android.trisolarisserver.controller import com.android.trisolarisserver.component.DocumentStorage import com.android.trisolarisserver.component.DocumentTokenService import com.android.trisolarisserver.component.ExtractionQueue import com.android.trisolarisserver.component.GuestDocumentEvents import com.android.trisolarisserver.component.DocumentExtractionService import com.android.trisolarisserver.component.PropertyAccess import com.android.trisolarisserver.db.repo.BookingRepo import com.android.trisolarisserver.db.repo.GuestDocumentRepo import com.android.trisolarisserver.db.repo.GuestRepo import com.android.trisolarisserver.models.booking.GuestDocument import com.android.trisolarisserver.models.property.Role import com.android.trisolarisserver.repo.AppUserRepo import com.android.trisolarisserver.repo.PropertyRepo import com.android.trisolarisserver.security.MyPrincipal import com.fasterxml.jackson.databind.ObjectMapper import org.springframework.core.io.FileSystemResource import org.springframework.http.HttpHeaders import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.transaction.annotation.Transactional import org.springframework.web.bind.annotation.* import org.springframework.http.MediaType import jakarta.servlet.http.HttpServletResponse import org.springframework.web.multipart.MultipartFile import org.springframework.web.server.ResponseStatusException import java.time.OffsetDateTime import java.nio.file.Files import java.nio.file.Paths import java.util.UUID import java.security.MessageDigest @RestController @RequestMapping("/properties/{propertyId}/guests/{guestId}/documents") class GuestDocuments( private val propertyAccess: PropertyAccess, private val propertyRepo: PropertyRepo, private val guestRepo: GuestRepo, private val bookingRepo: BookingRepo, private val guestDocumentRepo: GuestDocumentRepo, private val appUserRepo: AppUserRepo, private val storage: DocumentStorage, private val tokenService: DocumentTokenService, private val extractionQueue: ExtractionQueue, private val guestDocumentEvents: GuestDocumentEvents, private val extractionService: DocumentExtractionService, private val objectMapper: ObjectMapper, @org.springframework.beans.factory.annotation.Value("\${storage.documents.publicBaseUrl}") private val publicBaseUrl: String, @org.springframework.beans.factory.annotation.Value("\${storage.documents.aiBaseUrl:\${storage.documents.publicBaseUrl}}") private val aiBaseUrl: String ) { @PostMapping @ResponseStatus(HttpStatus.CREATED) fun uploadDocument( @PathVariable propertyId: UUID, @PathVariable guestId: UUID, @AuthenticationPrincipal principal: MyPrincipal?, @RequestParam("bookingId") bookingId: UUID, @RequestPart("file") file: MultipartFile ): GuestDocumentResponse { val user = requireUser(appUserRepo, principal) propertyAccess.requireMember(propertyId, user.id!!) propertyAccess.requireAnyRole(propertyId, user.id!!, Role.ADMIN, Role.MANAGER) if (file.isEmpty) { throw ResponseStatusException(HttpStatus.BAD_REQUEST, "File is empty") } val contentType = file.contentType if (contentType != null && contentType.startsWith("video/")) { throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Video files are not allowed") } val (property, guest) = requirePropertyGuest(propertyRepo, guestRepo, propertyId, guestId) val booking = bookingRepo.findById(bookingId).orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found") } if (booking.property.id != propertyId) { throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Booking not in property") } if (booking.primaryGuest?.id != guestId) { throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Booking not linked to guest") } val stored = storage.store(propertyId, guestId, bookingId, file) val fileHash = hashFile(stored.storagePath) if (fileHash != null && guestDocumentRepo.existsByPropertyIdAndGuestIdAndBookingIdAndFileHash( propertyId, guestId, bookingId, fileHash ) ) { Files.deleteIfExists(Paths.get(stored.storagePath)) throw ResponseStatusException(HttpStatus.CONFLICT, "Duplicate document") } val document = GuestDocument( property = property, guest = guest, booking = booking, uploadedBy = user, originalFilename = stored.originalFilename, contentType = stored.contentType, sizeBytes = stored.sizeBytes, storagePath = stored.storagePath, fileHash = fileHash ) val saved = guestDocumentRepo.save(document) runExtraction(saved.id!!, propertyId, guestId) guestDocumentEvents.emit(propertyId, guestId) return saved.toResponse(objectMapper) } @GetMapping fun listDocuments( @PathVariable propertyId: UUID, @PathVariable guestId: UUID, @AuthenticationPrincipal principal: MyPrincipal? ): List { requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER) return guestDocumentRepo .findByPropertyIdAndGuestIdOrderByUploadedAtDesc(propertyId, guestId) .map { it.toResponse(objectMapper) } } @GetMapping("/stream", produces = [MediaType.TEXT_EVENT_STREAM_VALUE]) fun streamDocuments( @PathVariable propertyId: UUID, @PathVariable guestId: UUID, @AuthenticationPrincipal principal: MyPrincipal?, response: HttpServletResponse ): org.springframework.web.servlet.mvc.method.annotation.SseEmitter { requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER) response.setHeader("Cache-Control", "no-cache") response.setHeader("X-Accel-Buffering", "no") return guestDocumentEvents.subscribe(propertyId, guestId) } @GetMapping("/{documentId}/file") fun downloadDocument( @PathVariable propertyId: UUID, @PathVariable guestId: UUID, @PathVariable documentId: UUID, @RequestParam(required = false) token: String?, @AuthenticationPrincipal principal: MyPrincipal? ): ResponseEntity { if (token == null) { requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER) } else if (!tokenService.validateToken(token, documentId.toString())) { throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid token") } val document = guestDocumentRepo.findByIdAndPropertyIdAndGuestId(documentId, propertyId, guestId) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Document not found") val path = Paths.get(document.storagePath) if (!Files.exists(path)) { throw ResponseStatusException(HttpStatus.NOT_FOUND, "File missing") } val resource = FileSystemResource(path) val type = document.contentType ?: MediaType.APPLICATION_OCTET_STREAM_VALUE return ResponseEntity.ok() .contentType(MediaType.parseMediaType(type)) .header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"${document.originalFilename}\"") .contentLength(document.sizeBytes) .body(resource) } @DeleteMapping("/{documentId}") @ResponseStatus(HttpStatus.NO_CONTENT) @Transactional fun deleteDocument( @PathVariable propertyId: UUID, @PathVariable guestId: UUID, @PathVariable documentId: UUID, @AuthenticationPrincipal principal: MyPrincipal? ) { requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER) val document = guestDocumentRepo.findByIdAndPropertyIdAndGuestId(documentId, propertyId, guestId) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Document not found") val status = document.booking.status if (status != com.android.trisolarisserver.models.booking.BookingStatus.OPEN && status != com.android.trisolarisserver.models.booking.BookingStatus.CHECKED_IN ) { throw ResponseStatusException( HttpStatus.BAD_REQUEST, "Documents can only be deleted for OPEN or CHECKED_IN bookings" ) } val path = Paths.get(document.storagePath) try { Files.deleteIfExists(path) } catch (_: Exception) { throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to delete file") } guestDocumentRepo.delete(document) guestDocumentEvents.emit(propertyId, guestId) } private fun runExtraction(documentId: UUID, propertyId: UUID, guestId: UUID) { extractionQueue.enqueue { val document = guestDocumentRepo.findById(documentId).orElse(null) ?: return@enqueue try { val token = tokenService.createToken(document.id.toString()) val imageUrl = "${aiBaseUrl}/properties/$propertyId/guests/$guestId/documents/${document.id}/file?token=$token" val publicImageUrl = "${publicBaseUrl.trimEnd('/')}/properties/$propertyId/guests/$guestId/documents/${document.id}/file?token=$token" val extraction = extractionService.extractAndApply(imageUrl, publicImageUrl, document, propertyId) val results = extraction.results document.extractedData = objectMapper.writeValueAsString(results) document.extractedAt = OffsetDateTime.now() guestDocumentRepo.save(document) guestDocumentEvents.emit(propertyId, guestId) } catch (_: Exception) { // Keep upload successful even if AI extraction fails. } } } private fun hashFile(storagePath: String): String? { return try { val path = Paths.get(storagePath) if (!Files.exists(path)) return null val digest = MessageDigest.getInstance("SHA-256") Files.newInputStream(path).use { input -> val buffer = ByteArray(DEFAULT_BUFFER_SIZE) var read = input.read(buffer) while (read >= 0) { if (read > 0) { digest.update(buffer, 0, read) } read = input.read(buffer) } } digest.digest().joinToString("") { "%02x".format(it) } } catch (_: Exception) { null } } }