From a275d009220f7f9fec81d41de7eaed285e9a4352 Mon Sep 17 00:00:00 2001 From: androidlover5842 Date: Sat, 31 Jan 2026 00:29:34 +0530 Subject: [PATCH] Add guest document SSE stream --- .../component/GuestDocumentEvents.kt | 108 ++++++++++++++++++ .../controller/GuestDocuments.kt | 15 +++ 2 files changed, 123 insertions(+) create mode 100644 src/main/kotlin/com/android/trisolarisserver/component/GuestDocumentEvents.kt diff --git a/src/main/kotlin/com/android/trisolarisserver/component/GuestDocumentEvents.kt b/src/main/kotlin/com/android/trisolarisserver/component/GuestDocumentEvents.kt new file mode 100644 index 0000000..6bc1d5f --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/component/GuestDocumentEvents.kt @@ -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> = 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() + 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() + 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 { + 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? = 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() + ) +} diff --git a/src/main/kotlin/com/android/trisolarisserver/controller/GuestDocuments.kt b/src/main/kotlin/com/android/trisolarisserver/controller/GuestDocuments.kt index b44cf5f..6018943 100644 --- a/src/main/kotlin/com/android/trisolarisserver/controller/GuestDocuments.kt +++ b/src/main/kotlin/com/android/trisolarisserver/controller/GuestDocuments.kt @@ -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. }