From b8c9f8dac46f5021af5c6fe33deef5bf99b99314 Mon Sep 17 00:00:00 2001 From: androidlover5842 Date: Sat, 24 Jan 2026 17:27:52 +0530 Subject: [PATCH] ai extraction --- .../component/DocumentStorage.kt | 51 ++++ .../component/DocumentTokenService.kt | 51 ++++ .../trisolarisserver/component/LlamaClient.kt | 39 +++ .../trisolarisserver/config/HttpConfig.kt | 18 ++ .../controller/GuestDocuments.kt | 262 ++++++++++++++++++ .../trisolarisserver/db/repo/BookingRepo.kt | 7 + .../db/repo/GuestDocumentRepo.kt | 10 + .../trisolarisserver/db/repo/GuestRepo.kt | 7 + .../models/booking/GuestDocument.kt | 53 ++++ 9 files changed, 498 insertions(+) create mode 100644 src/main/kotlin/com/android/trisolarisserver/component/DocumentStorage.kt create mode 100644 src/main/kotlin/com/android/trisolarisserver/component/DocumentTokenService.kt create mode 100644 src/main/kotlin/com/android/trisolarisserver/component/LlamaClient.kt create mode 100644 src/main/kotlin/com/android/trisolarisserver/config/HttpConfig.kt create mode 100644 src/main/kotlin/com/android/trisolarisserver/controller/GuestDocuments.kt create mode 100644 src/main/kotlin/com/android/trisolarisserver/db/repo/BookingRepo.kt create mode 100644 src/main/kotlin/com/android/trisolarisserver/db/repo/GuestDocumentRepo.kt create mode 100644 src/main/kotlin/com/android/trisolarisserver/db/repo/GuestRepo.kt create mode 100644 src/main/kotlin/com/android/trisolarisserver/models/booking/GuestDocument.kt diff --git a/src/main/kotlin/com/android/trisolarisserver/component/DocumentStorage.kt b/src/main/kotlin/com/android/trisolarisserver/component/DocumentStorage.kt new file mode 100644 index 0000000..6701fb6 --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/component/DocumentStorage.kt @@ -0,0 +1,51 @@ +package com.android.trisolarisserver.component + +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import org.springframework.web.multipart.MultipartFile +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import java.util.UUID + +@Component +class DocumentStorage( + @Value("\${storage.documents.root:/home/androidlover5842/docs}") + private val rootPath: String +) { + fun store( + propertyId: UUID, + guestId: UUID, + bookingId: UUID, + file: MultipartFile + ): StoredDocument { + val safeExt = file.originalFilename?.substringAfterLast('.', "")?.takeIf { it.isNotBlank() } + val fileName = buildString { + append(UUID.randomUUID().toString()) + if (safeExt != null) { + append('.') + append(safeExt.replace(Regex("[^A-Za-z0-9]"), "")) + } + } + + val dir = Paths.get(rootPath, propertyId.toString(), guestId.toString(), bookingId.toString()) + Files.createDirectories(dir) + val path = dir.resolve(fileName).normalize() + file.inputStream.use { input -> + Files.copy(input, path) + } + return StoredDocument( + storagePath = path.toString(), + originalFilename = file.originalFilename ?: fileName, + contentType = file.contentType, + sizeBytes = file.size + ) + } +} + +data class StoredDocument( + val storagePath: String, + val originalFilename: String, + val contentType: String?, + val sizeBytes: Long +) diff --git a/src/main/kotlin/com/android/trisolarisserver/component/DocumentTokenService.kt b/src/main/kotlin/com/android/trisolarisserver/component/DocumentTokenService.kt new file mode 100644 index 0000000..3f0a2de --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/component/DocumentTokenService.kt @@ -0,0 +1,51 @@ +package com.android.trisolarisserver.component + +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import java.nio.charset.StandardCharsets +import java.time.Instant +import java.util.Base64 +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec + +@Component +class DocumentTokenService( + @Value("\${storage.documents.tokenSecret}") + private val tokenSecret: String, + @Value("\${storage.documents.tokenTtlSeconds:300}") + private val ttlSeconds: Long +) { + fun createToken(documentId: String): String { + val exp = Instant.now().epochSecond + ttlSeconds + val payload = "$documentId:$exp" + val sig = hmac(payload) + val token = "$payload:$sig" + return Base64.getUrlEncoder().withoutPadding() + .encodeToString(token.toByteArray(StandardCharsets.UTF_8)) + } + + fun validateToken(token: String, documentId: String): Boolean { + val raw = try { + String(Base64.getUrlDecoder().decode(token), StandardCharsets.UTF_8) + } catch (_: IllegalArgumentException) { + return false + } + val parts = raw.split(":") + if (parts.size != 3) return false + val tokenDocId = parts[0] + val exp = parts[1].toLongOrNull() ?: return false + val sig = parts[2] + if (tokenDocId != documentId) return false + if (Instant.now().epochSecond > exp) return false + val expected = hmac("$tokenDocId:$exp") + return expected == sig + } + + private fun hmac(payload: String): String { + val mac = Mac.getInstance("HmacSHA256") + val key = SecretKeySpec(tokenSecret.toByteArray(StandardCharsets.UTF_8), "HmacSHA256") + mac.init(key) + val bytes = mac.doFinal(payload.toByteArray(StandardCharsets.UTF_8)) + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes) + } +} diff --git a/src/main/kotlin/com/android/trisolarisserver/component/LlamaClient.kt b/src/main/kotlin/com/android/trisolarisserver/component/LlamaClient.kt new file mode 100644 index 0000000..60d685c --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/component/LlamaClient.kt @@ -0,0 +1,39 @@ +package com.android.trisolarisserver.component + +import com.fasterxml.jackson.databind.ObjectMapper +import org.springframework.beans.factory.annotation.Value +import org.springframework.http.HttpEntity +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType +import org.springframework.stereotype.Component +import org.springframework.web.client.RestTemplate + +@Component +class LlamaClient( + private val restTemplate: RestTemplate, + private val objectMapper: ObjectMapper, + @Value("\${ai.llama.baseUrl}") + private val baseUrl: String +) { + fun ask(imageUrl: String, question: String): String { + val payload = mapOf( + "model" to "qwen", + "messages" to listOf( + mapOf( + "role" to "user", + "content" to listOf( + mapOf("type" to "text", "text" to question), + mapOf("type" to "image_url", "image_url" to mapOf("url" to imageUrl)) + ) + ) + ) + ) + val headers = HttpHeaders() + headers.contentType = MediaType.APPLICATION_JSON + val entity = HttpEntity(payload, headers) + val response = restTemplate.postForEntity(baseUrl, entity, String::class.java) + val body = response.body ?: return "" + val node = objectMapper.readTree(body) + return node.path("choices").path(0).path("message").path("content").asText() + } +} diff --git a/src/main/kotlin/com/android/trisolarisserver/config/HttpConfig.kt b/src/main/kotlin/com/android/trisolarisserver/config/HttpConfig.kt new file mode 100644 index 0000000..3f4cbad --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/config/HttpConfig.kt @@ -0,0 +1,18 @@ +package com.android.trisolarisserver.config + +import org.springframework.boot.web.client.RestTemplateBuilder +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.web.client.RestTemplate +import java.time.Duration + +@Configuration +class HttpConfig { + @Bean + fun restTemplate(builder: RestTemplateBuilder): RestTemplate { + return builder + .setConnectTimeout(Duration.ofSeconds(10)) + .setReadTimeout(Duration.ofSeconds(60)) + .build() + } +} diff --git a/src/main/kotlin/com/android/trisolarisserver/controller/GuestDocuments.kt b/src/main/kotlin/com/android/trisolarisserver/controller/GuestDocuments.kt new file mode 100644 index 0000000..6ff071f --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/controller/GuestDocuments.kt @@ -0,0 +1,262 @@ +package com.android.trisolarisserver.controller + +import com.android.trisolarisserver.component.DocumentStorage +import com.android.trisolarisserver.component.DocumentTokenService +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.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 + +@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 llamaClient: LlamaClient, + private val objectMapper: ObjectMapper, + @org.springframework.beans.factory.annotation.Value("\${storage.documents.publicBaseUrl}") + private val publicBaseUrl: 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(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 = propertyRepo.findById(propertyId).orElseThrow { + ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found") + } + val guest = guestRepo.findById(guestId).orElseThrow { + ResponseStatusException(HttpStatus.NOT_FOUND, "Guest not found") + } + if (guest.org.id != property.org.id) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Guest not in property org") + } + 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 document = GuestDocument( + property = property, + guest = guest, + booking = booking, + uploadedBy = user, + originalFilename = stored.originalFilename, + contentType = stored.contentType, + sizeBytes = stored.sizeBytes, + storagePath = stored.storagePath + ) + val saved = guestDocumentRepo.save(document) + runExtraction(saved, propertyId, guestId) + return guestDocumentRepo.save(saved).toResponse(objectMapper) + } + + @GetMapping + fun listDocuments( + @PathVariable propertyId: UUID, + @PathVariable guestId: UUID, + @AuthenticationPrincipal principal: MyPrincipal? + ): List { + requirePrincipal(principal) + propertyAccess.requireMember(propertyId, principal!!.userId) + propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN, Role.MANAGER) + + return guestDocumentRepo + .findByPropertyIdAndGuestIdOrderByUploadedAtDesc(propertyId, guestId) + .map { it.toResponse(objectMapper) } + } + + @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) { + requirePrincipal(principal) + propertyAccess.requireMember(propertyId, principal!!.userId) + propertyAccess.requireAnyRole(propertyId, principal.userId, 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) + } + + private fun runExtraction(document: GuestDocument, propertyId: UUID, guestId: UUID) { + try { + val token = tokenService.createToken(document.id.toString()) + val imageUrl = + "${publicBaseUrl}/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() + } catch (_: Exception) { + // Keep upload successful even if AI extraction fails. + } + } + + private fun requirePrincipal(principal: MyPrincipal?) { + if (principal == null) { + throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal") + } + } + + private fun requireUser(principal: MyPrincipal?): com.android.trisolarisserver.models.property.AppUser { + if (principal == null) { + throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal") + } + return appUserRepo.findById(principal.userId).orElseThrow { + ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found") + } + } +} + +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 = extractedData?.let { + try { + objectMapper.readValue(it, Map::class.java).mapValues { entry -> 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/db/repo/BookingRepo.kt b/src/main/kotlin/com/android/trisolarisserver/db/repo/BookingRepo.kt new file mode 100644 index 0000000..016033e --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/db/repo/BookingRepo.kt @@ -0,0 +1,7 @@ +package com.android.trisolarisserver.db.repo + +import com.android.trisolarisserver.models.booking.Booking +import org.springframework.data.jpa.repository.JpaRepository +import java.util.UUID + +interface BookingRepo : JpaRepository diff --git a/src/main/kotlin/com/android/trisolarisserver/db/repo/GuestDocumentRepo.kt b/src/main/kotlin/com/android/trisolarisserver/db/repo/GuestDocumentRepo.kt new file mode 100644 index 0000000..034066f --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/db/repo/GuestDocumentRepo.kt @@ -0,0 +1,10 @@ +package com.android.trisolarisserver.db.repo + +import com.android.trisolarisserver.models.booking.GuestDocument +import org.springframework.data.jpa.repository.JpaRepository +import java.util.UUID + +interface GuestDocumentRepo : JpaRepository { + fun findByPropertyIdAndGuestIdOrderByUploadedAtDesc(propertyId: UUID, guestId: UUID): List + fun findByIdAndPropertyIdAndGuestId(id: UUID, propertyId: UUID, guestId: UUID): GuestDocument? +} diff --git a/src/main/kotlin/com/android/trisolarisserver/db/repo/GuestRepo.kt b/src/main/kotlin/com/android/trisolarisserver/db/repo/GuestRepo.kt new file mode 100644 index 0000000..df74e90 --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/db/repo/GuestRepo.kt @@ -0,0 +1,7 @@ +package com.android.trisolarisserver.db.repo + +import com.android.trisolarisserver.models.booking.Guest +import org.springframework.data.jpa.repository.JpaRepository +import java.util.UUID + +interface GuestRepo : JpaRepository diff --git a/src/main/kotlin/com/android/trisolarisserver/models/booking/GuestDocument.kt b/src/main/kotlin/com/android/trisolarisserver/models/booking/GuestDocument.kt new file mode 100644 index 0000000..4bb66c0 --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/models/booking/GuestDocument.kt @@ -0,0 +1,53 @@ +package com.android.trisolarisserver.models.booking + +import com.android.trisolarisserver.models.property.AppUser +import com.android.trisolarisserver.models.property.Property +import jakarta.persistence.* +import java.time.OffsetDateTime +import java.util.UUID + +@Entity +@Table(name = "guest_document") +class GuestDocument( + @Id + @GeneratedValue + @Column(columnDefinition = "uuid") + val id: UUID? = null, + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "property_id", nullable = false) + var property: Property, + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "guest_id", nullable = false) + var guest: Guest, + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "booking_id", nullable = false) + var booking: Booking, + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "uploaded_by", nullable = false) + var uploadedBy: AppUser, + + @Column(name = "original_filename", nullable = false) + var originalFilename: String, + + @Column(name = "content_type") + var contentType: String? = null, + + @Column(name = "size_bytes", nullable = false) + var sizeBytes: Long, + + @Column(name = "storage_path", nullable = false) + var storagePath: String, + + @Column(name = "extracted_data", columnDefinition = "jsonb") + var extractedData: String? = null, + + @Column(name = "extracted_at", columnDefinition = "timestamptz") + var extractedAt: OffsetDateTime? = null, + + @Column(name = "uploaded_at", nullable = false, columnDefinition = "timestamptz") + val uploadedAt: OffsetDateTime = OffsetDateTime.now() +)