Add guest document SSE stream
All checks were successful
build-and-deploy / build-deploy (push) Successful in 31s

This commit is contained in:
androidlover5842
2026-01-31 00:29:34 +05:30
parent 0c32ff4102
commit a275d00922
2 changed files with 123 additions and 0 deletions

View File

@@ -0,0 +1,108 @@
package com.android.trisolarisserver.component
import com.android.trisolarisserver.controller.GuestDocumentResponse
import com.android.trisolarisserver.db.repo.GuestDocumentRepo
import com.android.trisolarisserver.models.booking.GuestDocument
import com.fasterxml.jackson.databind.ObjectMapper
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Component
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter
import java.io.IOException
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList
@Component
class GuestDocumentEvents(
private val guestDocumentRepo: GuestDocumentRepo,
private val objectMapper: ObjectMapper
) {
private val emitters: MutableMap<GuestDocKey, CopyOnWriteArrayList<SseEmitter>> = ConcurrentHashMap()
fun subscribe(propertyId: UUID, guestId: UUID): SseEmitter {
val key = GuestDocKey(propertyId, guestId)
val emitter = SseEmitter(0L)
emitters.computeIfAbsent(key) { CopyOnWriteArrayList() }.add(emitter)
emitter.onCompletion { emitters[key]?.remove(emitter) }
emitter.onTimeout { emitters[key]?.remove(emitter) }
emitter.onError { emitters[key]?.remove(emitter) }
try {
emitter.send(SseEmitter.event().name("guest-documents").data(buildSnapshot(propertyId, guestId)))
} catch (_: IOException) {
emitters[key]?.remove(emitter)
}
return emitter
}
fun emit(propertyId: UUID, guestId: UUID) {
val key = GuestDocKey(propertyId, guestId)
val list = emitters[key] ?: return
val data = buildSnapshot(propertyId, guestId)
val dead = mutableListOf<SseEmitter>()
for (emitter in list) {
try {
emitter.send(SseEmitter.event().name("guest-documents").data(data))
} catch (_: IOException) {
dead.add(emitter)
}
}
if (dead.isNotEmpty()) {
list.removeAll(dead.toSet())
}
}
@Scheduled(fixedDelayString = "25000")
fun heartbeat() {
emitters.forEach { (_, list) ->
val dead = mutableListOf<SseEmitter>()
for (emitter in list) {
try {
emitter.send(SseEmitter.event().name("ping").data("ok"))
} catch (_: IOException) {
dead.add(emitter)
}
}
if (dead.isNotEmpty()) {
list.removeAll(dead.toSet())
}
}
}
private fun buildSnapshot(propertyId: UUID, guestId: UUID): List<GuestDocumentResponse> {
return guestDocumentRepo
.findByPropertyIdAndGuestIdOrderByUploadedAtDesc(propertyId, guestId)
.map { it.toResponse(objectMapper) }
}
}
private data class GuestDocKey(
val propertyId: UUID,
val guestId: UUID
)
private fun GuestDocument.toResponse(objectMapper: ObjectMapper): GuestDocumentResponse {
val id = id ?: throw IllegalStateException("Document id missing")
val extracted: Map<String, String>? = 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()
)
}

View File

@@ -3,6 +3,7 @@ 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
@@ -42,6 +43,7 @@ class GuestDocuments(
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}")
@@ -107,6 +109,7 @@ class GuestDocuments(
)
val saved = guestDocumentRepo.save(document)
runExtraction(saved.id!!, propertyId, guestId)
guestDocumentEvents.emit(propertyId, guestId)
return saved.toResponse(objectMapper)
}
@@ -123,6 +126,16 @@ class GuestDocuments(
.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,
@@ -183,6 +196,7 @@ class GuestDocuments(
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) {
@@ -243,6 +257,7 @@ class GuestDocuments(
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.
}