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.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 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", 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 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.") val hasAadhar = isYes(results["hasAadhar"]) || isYes(results["hasUidai"]) if (hasAadhar) { val aadharQuestions = linkedMapOf( "hasAddress" to "POSTAL ADDRESS PRESENT? Answer YES or NO only.", "hasDob" to "DOB? Reply YES or NO.", "hasGenderMentioned" to "GENDER MENTIONED? Reply YES or NO." ) for ((key, question) in aadharQuestions) { results[key] = llamaClient.ask(imageUrl, question) } val hasAddress = isYes(results["hasAddress"]) if (hasAddress) { val addressQuestions = linkedMapOf( "pinCode" to "POSTAL ADDRESS PIN CODE (6 digit)? Reply only pin or NONE.", "address" to "POSTAL ADDRESS ONLY (street/area/city/state). Ignore IDs, UUIDs, and codes. Reply only address or NONE." ) for ((key, question) in addressQuestions) { results[key] = llamaClient.ask(imageUrl, question) } } val hasDob = isYes(results["hasDob"]) val hasGender = isYes(results["hasGenderMentioned"]) if (hasDob && hasGender) { val aadharFrontQuestions = linkedMapOf( "name" to "NAME? Reply only the name or NONE.", "dob" to "DOB? Reply only date or NONE.", "idNumber" to "ID NUMBER? Reply only number or NONE.", "gender" to "GENDER? Reply only MALE/FEMALE/OTHER or NONE." ) for ((key, question) in aadharFrontQuestions) { results[key] = llamaClient.ask(imageUrl, question) } } } else { val detectionQuestions = linkedMapOf( "hasDrivingLicence" to "CONTAINS DRIVING LICENCE? Answer YES or NO only.", "hasTransportDept" to "CONTAINS TRANSPORT DEPARTMENT? Answer YES or NO only.", "hasElectionCommission" to "CONTAINS ELECTION COMMISSION OF INDIA? Answer YES or NO only.", "hasIncomeTaxDept" to "CONTAINS INCOME TAX DEPARTMENT? Answer YES or NO only.", "hasPassport" to "CONTAINS PASSPORT? Answer YES or NO only." ) for ((key, question) in detectionQuestions) { results[key] = llamaClient.ask(imageUrl, question) } val isDriving = isYes(results["hasDrivingLicence"]) || isYes(results["hasTransportDept"]) if (isDriving) { val drivingQuestions = linkedMapOf( "name" to "NAME? Reply only the name or NONE.", "dob" to "DOB? Reply only date or NONE.", "idNumber" to "DL NUMBER? Reply only number or NONE.", "address" to "POSTAL ADDRESS ONLY (street/area/city/state). Ignore IDs, UUIDs, and codes. Reply only address or NONE.", "pinCode" to "PIN CODE? Reply only pin or NONE.", "city" to "CITY? Reply only city or NONE.", "gender" to "GENDER? Reply only MALE/FEMALE/OTHER or NONE.", "nationality" to "NATIONALITY? Reply only nationality or NONE." ) for ((key, question) in drivingQuestions) { results[key] = llamaClient.ask(imageUrl, question) } } else if (isYes(results["hasElectionCommission"])) { val voterQuestions = linkedMapOf( "name" to "NAME? Reply only the name or NONE.", "dob" to "DOB? Reply only date or NONE.", "idNumber" to "VOTER ID NUMBER? Reply only number or NONE.", "address" to "POSTAL ADDRESS ONLY (street/area/city/state). Ignore IDs, UUIDs, and codes. Reply only address or NONE.", "pinCode" to "PIN CODE? Reply only pin or NONE.", "city" to "CITY? Reply only city or NONE.", "gender" to "GENDER? Reply only MALE/FEMALE/OTHER or NONE.", "nationality" to "NATIONALITY? Reply only nationality or NONE." ) for ((key, question) in voterQuestions) { results[key] = llamaClient.ask(imageUrl, question) } } else if (isYes(results["hasIncomeTaxDept"])) { val panQuestions = linkedMapOf( "name" to "NAME? Reply only the name or NONE.", "dob" to "DOB? Reply only date or NONE.", "idNumber" to "PAN NUMBER? Reply only number or NONE.", "address" to "POSTAL ADDRESS ONLY (street/area/city/state). Ignore IDs, UUIDs, and codes. Reply only address or NONE.", "pinCode" to "PIN CODE? Reply only pin or NONE.", "city" to "CITY? Reply only city or NONE.", "gender" to "GENDER? Reply only MALE/FEMALE/OTHER or NONE.", "nationality" to "NATIONALITY? Reply only nationality or NONE." ) for ((key, question) in panQuestions) { results[key] = llamaClient.ask(imageUrl, question) } } else if (isYes(results["hasPassport"])) { val passportQuestions = linkedMapOf( "name" to "NAME? Reply only the name or NONE.", "dob" to "DOB? Reply only date or NONE.", "idNumber" to "PASSPORT NUMBER? Reply only number or NONE.", "address" to "POSTAL ADDRESS ONLY (street/area/city/state). Ignore IDs, UUIDs, and codes. Reply only address or NONE.", "pinCode" to "PIN CODE? Reply only pin or NONE.", "city" to "CITY? Reply only city or NONE.", "gender" to "GENDER? Reply only MALE/FEMALE/OTHER or NONE.", "nationality" to "NATIONALITY? Reply only nationality or NONE." ) for ((key, question) in passportQuestions) { results[key] = llamaClient.ask(imageUrl, question) } } else { val generalQuestions = linkedMapOf( "name" to "NAME? Reply only the name or NONE.", "dob" to "DOB? Reply only date or NONE.", "idNumber" to "ID NUMBER? Reply only number or NONE.", "address" to "POSTAL ADDRESS ONLY (street/area/city/state). Ignore IDs, UUIDs, and codes. Reply only address or NONE.", "vehicleNumber" to "VEHICLE NUMBER? Reply only number or NONE.", "pinCode" to "PIN CODE? Reply only pin or NONE.", "city" to "CITY? Reply only city or NONE.", "gender" to "GENDER? Reply only MALE/FEMALE/OTHER or NONE.", "nationality" to "NATIONALITY? Reply only nationality or NONE." ) for ((key, question) in generalQuestions) { results[key] = llamaClient.ask(imageUrl, question) } } } results["docType"] = when { isYes(results["hasCourt"]) || isYes(results["hasHighCourt"]) || isYes(results["hasSupremeCourt"]) || isYes(results["hasJudiciary"]) -> "COURT_ID" isYes(results["hasPolice"]) -> "POLICE_ID" isYes(results["hasPassport"]) -> "PASSPORT" isYes(results["hasTransportDept"]) || isYes(results["hasDrivingLicence"]) -> "TRANSPORT" isYes(results["hasIncomeTaxDept"]) -> "PAN" isYes(results["hasElectionCommission"]) -> "VOTER_ID" isYes(results["hasAadhar"]) || isYes(results["hasUidai"]) -> { if (isYes(results["hasAddress"])) "AADHAR_BACK" else "AADHAR_FRONT" } results["vehicleNumber"].orEmpty().isNotBlank() && !results["vehicleNumber"]!!.contains("NONE", true) -> "VEHICLE" isYes(results["isVehiclePhoto"]) -> "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() ) } private fun isYes(value: String?): Boolean { return value.orEmpty().contains("YES", ignoreCase = true) }