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.LlamaClient 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.MediaType 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.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 llamaClient: LlamaClient, 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") fun streamDocuments( @PathVariable propertyId: UUID, @PathVariable guestId: UUID, @AuthenticationPrincipal principal: MyPrincipal? ): org.springframework.web.servlet.mvc.method.annotation.SseEmitter { requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER) 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 results = linkedMapOf() results["hasAadhar"] = llamaClient.ask(imageUrl, "CONTAINS AADHAAR? Answer YES or NO only.") results["hasUidai"] = llamaClient.ask(imageUrl, "CONTAINS UIDAI? Answer YES or NO only.") results["hasTransportDept"] = llamaClient.ask(imageUrl, "CONTAINS TRANSPORT DEPARTMENT? Answer YES or NO only.") results["hasIncomeTaxDept"] = llamaClient.ask(imageUrl, "CONTAINS INCOME TAX DEPARTMENT? Answer YES or NO only.") results["hasElectionCommission"] = llamaClient.ask(imageUrl, "CONTAINS ELECTION COMMISSION OF INDIA? Answer YES or NO only.") results["hasDrivingLicence"] = llamaClient.ask(imageUrl, "CONTAINS DRIVING LICENCE? Answer YES or NO only.") results["hasPassport"] = llamaClient.ask(imageUrl, "CONTAINS PASSPORT? Answer YES or NO only.") results["hasPolice"] = llamaClient.ask(imageUrl, "CONTAINS POLICE? Answer YES or NO only.") results["hasCourt"] = llamaClient.ask(imageUrl, "CONTAINS COURT? Answer YES or NO only.") results["hasHighCourt"] = llamaClient.ask(imageUrl, "CONTAINS HIGH COURT? Answer YES or NO only.") results["hasSupremeCourt"] = llamaClient.ask(imageUrl, "CONTAINS SUPREME COURT? Answer YES or NO only.") results["hasJudiciary"] = llamaClient.ask(imageUrl, "CONTAINS JUDICIARY? Answer YES or NO only.") results["hasAddress"] = llamaClient.ask(imageUrl, "ADDRESS PRESENT? Answer YES or NO only.") results["hasGender"] = llamaClient.ask(imageUrl, "GENDER PRESENT? Answer YES or NO only.") results["hasNationality"] = llamaClient.ask(imageUrl, "NATIONALITY PRESENT? Answer YES or NO only.") results["name"] = llamaClient.ask(imageUrl, "NAME? Reply only the name or NONE.") results["dob"] = llamaClient.ask(imageUrl, "DOB? Reply only date or NONE.") results["idNumber"] = llamaClient.ask(imageUrl, "ID NUMBER? Reply only number or NONE.") results["address"] = llamaClient.ask(imageUrl, "ADDRESS? Reply only address or NONE.") results["vehicleNumber"] = llamaClient.ask(imageUrl, "VEHICLE NUMBER? Reply only number or NONE.") results["isVehiclePhoto"] = llamaClient.ask(imageUrl, "IS THIS A VEHICLE PHOTO? Answer YES or NO only.") results["pinCode"] = llamaClient.ask(imageUrl, "PIN CODE? Reply only pin or NONE.") results["city"] = llamaClient.ask(imageUrl, "CITY? Reply only city or NONE.") results["gender"] = llamaClient.ask(imageUrl, "GENDER? Reply only MALE/FEMALE/OTHER or NONE.") results["nationality"] = llamaClient.ask(imageUrl, "NATIONALITY? Reply only nationality or NONE.") results["docType"] = when { results["hasCourt"].orEmpty().contains("YES", ignoreCase = true) || results["hasHighCourt"].orEmpty().contains("YES", ignoreCase = true) || results["hasSupremeCourt"].orEmpty().contains("YES", ignoreCase = true) || results["hasJudiciary"].orEmpty().contains("YES", ignoreCase = true) -> "COURT_ID" results["hasPolice"].orEmpty().contains("YES", ignoreCase = true) -> "POLICE_ID" results["hasPassport"].orEmpty().contains("YES", ignoreCase = true) -> "PASSPORT" results["hasTransportDept"].orEmpty().contains("YES", ignoreCase = true) || results["hasDrivingLicence"].orEmpty().contains("YES", ignoreCase = true) -> "TRANSPORT" results["hasIncomeTaxDept"].orEmpty().contains("YES", ignoreCase = true) -> "PAN" results["hasElectionCommission"].orEmpty().contains("YES", ignoreCase = true) -> "VOTER_ID" results["hasAadhar"].orEmpty().contains("YES", ignoreCase = true) || results["hasUidai"].orEmpty().contains("YES", ignoreCase = true) -> { if (results["hasAddress"].orEmpty().contains("YES", ignoreCase = true)) "AADHAR_BACK" else "AADHAR_FRONT" } results["vehicleNumber"].orEmpty().isNotBlank() && !results["vehicleNumber"]!!.contains("NONE", true) -> "VEHICLE" results["isVehiclePhoto"].orEmpty().contains("YES", ignoreCase = true) -> "VEHICLE_PHOTO" else -> "UNKNOWN" } 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 } } } data class GuestDocumentResponse( val id: UUID, val propertyId: UUID, val guestId: UUID, val bookingId: UUID, val uploadedByUserId: UUID, val uploadedAt: String, val originalFilename: String, val contentType: String?, val sizeBytes: Long, val extractedData: Map?, val extractedAt: String? ) private fun GuestDocument.toResponse(objectMapper: ObjectMapper): GuestDocumentResponse { val id = id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Document id missing") val extracted: Map? = extractedData?.let { try { val raw = objectMapper.readValue(it, Map::class.java) raw.entries.associate { entry -> entry.key.toString() to (entry.value?.toString() ?: "") } } catch (_: Exception) { null } } return GuestDocumentResponse( id = id, propertyId = property.id!!, guestId = guest.id!!, bookingId = booking.id!!, uploadedByUserId = uploadedBy.id!!, uploadedAt = uploadedAt.toString(), originalFilename = originalFilename, contentType = contentType, sizeBytes = sizeBytes, extractedData = extracted, extractedAt = extractedAt?.toString() ) }