From ac79d6d1c0f498fe0eb4acc5e894861795ab5d7e Mon Sep 17 00:00:00 2001 From: androidlover5842 Date: Sat, 24 Jan 2026 19:22:37 +0530 Subject: [PATCH] ai creates booking --- build.gradle.kts | 2 + .../TrisolarisServerApplication.kt | 2 + .../component/EmailStorage.kt | 85 +++++ .../trisolarisserver/component/LlamaClient.kt | 33 ++ .../trisolarisserver/config/HttpConfig.kt | 2 +- .../trisolarisserver/config/JacksonConfig.kt | 14 + .../controller/GuestDocuments.kt | 7 +- .../trisolarisserver/controller/Properties.kt | 9 +- .../controller/dto/OrgPropertyDtos.kt | 9 +- .../trisolarisserver/db/repo/BookingRepo.kt | 5 +- .../trisolarisserver/db/repo/GuestRepo.kt | 4 +- .../db/repo/InboundEmailRepo.kt | 12 + .../models/booking/InboundEmail.kt | 68 ++++ .../models/property/Property.kt | 8 + .../service/EmailIngestionService.kt | 298 ++++++++++++++++++ src/main/resources/application-dev.properties | 1 + .../resources/application-prod.properties | 3 +- src/main/resources/application.properties | 9 +- 18 files changed, 559 insertions(+), 12 deletions(-) create mode 100644 src/main/kotlin/com/android/trisolarisserver/component/EmailStorage.kt create mode 100644 src/main/kotlin/com/android/trisolarisserver/config/JacksonConfig.kt create mode 100644 src/main/kotlin/com/android/trisolarisserver/db/repo/InboundEmailRepo.kt create mode 100644 src/main/kotlin/com/android/trisolarisserver/models/booking/InboundEmail.kt create mode 100644 src/main/kotlin/com/android/trisolarisserver/service/EmailIngestionService.kt diff --git a/build.gradle.kts b/build.gradle.kts index b244991..0428926 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -31,6 +31,8 @@ dependencies { implementation("tools.jackson.module:jackson-module-kotlin") implementation("org.springframework.boot:spring-boot-starter-security") implementation("com.google.firebase:firebase-admin:9.7.0") + implementation("com.sun.mail:jakarta.mail:2.0.1") + implementation("org.apache.pdfbox:pdfbox:2.0.30") runtimeOnly("org.postgresql:postgresql") testImplementation("org.springframework.boot:spring-boot-starter-data-jpa-test") testImplementation("org.springframework.boot:spring-boot-starter-flyway-test") diff --git a/src/main/kotlin/com/android/trisolarisserver/TrisolarisServerApplication.kt b/src/main/kotlin/com/android/trisolarisserver/TrisolarisServerApplication.kt index d262ea9..daf0994 100644 --- a/src/main/kotlin/com/android/trisolarisserver/TrisolarisServerApplication.kt +++ b/src/main/kotlin/com/android/trisolarisserver/TrisolarisServerApplication.kt @@ -2,8 +2,10 @@ package com.android.trisolarisserver import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.runApplication +import org.springframework.scheduling.annotation.EnableScheduling @SpringBootApplication +@EnableScheduling class TrisolarisServerApplication fun main(args: Array) { diff --git a/src/main/kotlin/com/android/trisolarisserver/component/EmailStorage.kt b/src/main/kotlin/com/android/trisolarisserver/component/EmailStorage.kt new file mode 100644 index 0000000..6325a7b --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/component/EmailStorage.kt @@ -0,0 +1,85 @@ +package com.android.trisolarisserver.component + +import org.apache.pdfbox.pdmodel.PDDocument +import org.apache.pdfbox.pdmodel.PDPage +import org.apache.pdfbox.pdmodel.PDPageContentStream +import org.apache.pdfbox.pdmodel.common.PDRectangle +import org.apache.pdfbox.pdmodel.font.PDType1Font +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import java.time.OffsetDateTime +import java.util.UUID + +@Component +class EmailStorage( + @Value("\${storage.emails.root:/home/androidlover5842/docs/emails}") + private val rootPath: String +) { + fun storePdf(propertyId: UUID?, messageId: String?, subject: String?, body: String): String { + val dir = if (propertyId != null) { + Paths.get(rootPath, propertyId.toString()) + } else { + Paths.get(rootPath, "unassigned") + } + Files.createDirectories(dir) + val safeName = (messageId ?: UUID.randomUUID().toString()).replace(Regex("[^A-Za-z0-9._-]"), "_") + val fileName = "${safeName}_${OffsetDateTime.now().toEpochSecond()}.pdf" + val path = dir.resolve(fileName).normalize() + + val document = PDDocument() + val page = PDPage(PDRectangle.LETTER) + document.addPage(page) + val header = "Subject: ${subject ?: ""}\n\n" + writeText(document, page, header + body) + document.save(path.toFile()) + document.close() + return path.toString() + } + + private fun writeText(document: PDDocument, firstPage: PDPage, text: String) { + var page = firstPage + var y = 730f + val marginX = 50f + val lineHeight = 14f + val maxLines = 48 + var linesOnPage = 0 + + var content = PDPageContentStream(document, page) + content.beginText() + content.setFont(PDType1Font.HELVETICA, 11f) + content.newLineAtOffset(marginX, y) + + fun newLine() { + content.newLineAtOffset(0f, -lineHeight) + y -= lineHeight + linesOnPage++ + if (linesOnPage >= maxLines) { + content.endText() + content.close() + page = PDPage(PDRectangle.LETTER) + document.addPage(page) + content = PDPageContentStream(document, page) + content.beginText() + content.setFont(PDType1Font.HELVETICA, 11f) + y = 730f + linesOnPage = 0 + content.newLineAtOffset(marginX, y) + } + } + + val lines = text.split("\n") + for (line in lines) { + val chunks = line.chunked(90) + for (chunk in chunks) { + content.showText(chunk) + newLine() + } + } + + content.endText() + content.close() + } +} diff --git a/src/main/kotlin/com/android/trisolarisserver/component/LlamaClient.kt b/src/main/kotlin/com/android/trisolarisserver/component/LlamaClient.kt index 60d685c..da94413 100644 --- a/src/main/kotlin/com/android/trisolarisserver/component/LlamaClient.kt +++ b/src/main/kotlin/com/android/trisolarisserver/component/LlamaClient.kt @@ -15,10 +15,20 @@ class LlamaClient( @Value("\${ai.llama.baseUrl}") private val baseUrl: String ) { + private val systemPrompt = + "Look only at visible text. " + + "Return the exact text you can read verbatim. " + + "If the text is unclear, partial, or inferred, return NOT CLEARLY VISIBLE. " + + "Do not guess. Do not explain." + fun ask(imageUrl: String, question: String): String { val payload = mapOf( "model" to "qwen", "messages" to listOf( + mapOf( + "role" to "system", + "content" to systemPrompt + ), mapOf( "role" to "user", "content" to listOf( @@ -36,4 +46,27 @@ class LlamaClient( val node = objectMapper.readTree(body) return node.path("choices").path(0).path("message").path("content").asText() } + + fun askText(content: String, question: String): String { + val payload = mapOf( + "model" to "qwen", + "messages" to listOf( + mapOf( + "role" to "system", + "content" to systemPrompt + ), + mapOf( + "role" to "user", + "content" to "${question}\n\nEMAIL:\n${content}" + ) + ) + ) + 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 index 3f4cbad..d6fcb95 100644 --- a/src/main/kotlin/com/android/trisolarisserver/config/HttpConfig.kt +++ b/src/main/kotlin/com/android/trisolarisserver/config/HttpConfig.kt @@ -12,7 +12,7 @@ class HttpConfig { fun restTemplate(builder: RestTemplateBuilder): RestTemplate { return builder .setConnectTimeout(Duration.ofSeconds(10)) - .setReadTimeout(Duration.ofSeconds(60)) + .setReadTimeout(Duration.ofMinutes(5)) .build() } } diff --git a/src/main/kotlin/com/android/trisolarisserver/config/JacksonConfig.kt b/src/main/kotlin/com/android/trisolarisserver/config/JacksonConfig.kt new file mode 100644 index 0000000..c88b3fd --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/config/JacksonConfig.kt @@ -0,0 +1,14 @@ +package com.android.trisolarisserver.config + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class JacksonConfig { + @Bean + fun objectMapper(): ObjectMapper { + return jacksonObjectMapper() + } +} diff --git a/src/main/kotlin/com/android/trisolarisserver/controller/GuestDocuments.kt b/src/main/kotlin/com/android/trisolarisserver/controller/GuestDocuments.kt index 6ff071f..7774f3f 100644 --- a/src/main/kotlin/com/android/trisolarisserver/controller/GuestDocuments.kt +++ b/src/main/kotlin/com/android/trisolarisserver/controller/GuestDocuments.kt @@ -239,9 +239,12 @@ data class GuestDocumentResponse( private fun GuestDocument.toResponse(objectMapper: ObjectMapper): GuestDocumentResponse { val id = id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Document id missing") - val extracted = extractedData?.let { + val extracted: Map? = extractedData?.let { try { - objectMapper.readValue(it, Map::class.java).mapValues { entry -> entry.value?.toString() ?: "" } + val raw = objectMapper.readValue(it, Map::class.java) + raw.entries.associate { entry -> + entry.key.toString() to (entry.value?.toString() ?: "") + } } catch (_: Exception) { null } diff --git a/src/main/kotlin/com/android/trisolarisserver/controller/Properties.kt b/src/main/kotlin/com/android/trisolarisserver/controller/Properties.kt index fa437d3..4949cf4 100644 --- a/src/main/kotlin/com/android/trisolarisserver/controller/Properties.kt +++ b/src/main/kotlin/com/android/trisolarisserver/controller/Properties.kt @@ -64,7 +64,8 @@ class Properties( name = request.name, timezone = request.timezone ?: "Asia/Kolkata", currency = request.currency ?: "INR", - active = request.active ?: true + active = request.active ?: true, + emailAliases = request.emailAliases?.toMutableSet() ?: mutableSetOf() ) val saved = propertyRepo.save(property) return saved.toResponse() @@ -205,6 +206,9 @@ class Properties( property.timezone = request.timezone ?: property.timezone property.currency = request.currency ?: property.currency property.active = request.active ?: property.active + if (request.emailAliases != null) { + property.emailAliases = request.emailAliases.toMutableSet() + } return propertyRepo.save(property).toResponse() } @@ -241,7 +245,8 @@ private fun Property.toResponse(): PropertyResponse { name = name, timezone = timezone, currency = currency, - active = active + active = active, + emailAliases = emailAliases.toSet() ) } diff --git a/src/main/kotlin/com/android/trisolarisserver/controller/dto/OrgPropertyDtos.kt b/src/main/kotlin/com/android/trisolarisserver/controller/dto/OrgPropertyDtos.kt index f5fbcb0..75b3ba9 100644 --- a/src/main/kotlin/com/android/trisolarisserver/controller/dto/OrgPropertyDtos.kt +++ b/src/main/kotlin/com/android/trisolarisserver/controller/dto/OrgPropertyDtos.kt @@ -16,7 +16,8 @@ data class PropertyCreateRequest( val name: String, val timezone: String? = null, val currency: String? = null, - val active: Boolean? = null + val active: Boolean? = null, + val emailAliases: Set? = null ) data class PropertyUpdateRequest( @@ -24,7 +25,8 @@ data class PropertyUpdateRequest( val name: String, val timezone: String? = null, val currency: String? = null, - val active: Boolean? = null + val active: Boolean? = null, + val emailAliases: Set? = null ) data class PropertyResponse( @@ -34,7 +36,8 @@ data class PropertyResponse( val name: String, val timezone: String, val currency: String, - val active: Boolean + val active: Boolean, + val emailAliases: Set ) data class UserResponse( diff --git a/src/main/kotlin/com/android/trisolarisserver/db/repo/BookingRepo.kt b/src/main/kotlin/com/android/trisolarisserver/db/repo/BookingRepo.kt index 016033e..6b441fc 100644 --- a/src/main/kotlin/com/android/trisolarisserver/db/repo/BookingRepo.kt +++ b/src/main/kotlin/com/android/trisolarisserver/db/repo/BookingRepo.kt @@ -4,4 +4,7 @@ import com.android.trisolarisserver.models.booking.Booking import org.springframework.data.jpa.repository.JpaRepository import java.util.UUID -interface BookingRepo : JpaRepository +interface BookingRepo : JpaRepository { + fun findByPropertyIdAndSourceBookingId(propertyId: UUID, sourceBookingId: String): Booking? + fun existsByPropertyIdAndSourceBookingId(propertyId: UUID, sourceBookingId: String): Boolean +} diff --git a/src/main/kotlin/com/android/trisolarisserver/db/repo/GuestRepo.kt b/src/main/kotlin/com/android/trisolarisserver/db/repo/GuestRepo.kt index df74e90..2b68827 100644 --- a/src/main/kotlin/com/android/trisolarisserver/db/repo/GuestRepo.kt +++ b/src/main/kotlin/com/android/trisolarisserver/db/repo/GuestRepo.kt @@ -4,4 +4,6 @@ import com.android.trisolarisserver.models.booking.Guest import org.springframework.data.jpa.repository.JpaRepository import java.util.UUID -interface GuestRepo : JpaRepository +interface GuestRepo : JpaRepository { + fun findByOrgIdAndPhoneE164(orgId: UUID, phoneE164: String): Guest? +} diff --git a/src/main/kotlin/com/android/trisolarisserver/db/repo/InboundEmailRepo.kt b/src/main/kotlin/com/android/trisolarisserver/db/repo/InboundEmailRepo.kt new file mode 100644 index 0000000..dc3375a --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/db/repo/InboundEmailRepo.kt @@ -0,0 +1,12 @@ +package com.android.trisolarisserver.db.repo + +import com.android.trisolarisserver.models.booking.InboundEmail +import org.springframework.data.jpa.repository.JpaRepository +import java.util.UUID + +interface InboundEmailRepo : JpaRepository { + fun findByMessageId(messageId: String): InboundEmail? + fun findByPropertyIdAndOtaBookingId(propertyId: UUID, otaBookingId: String): InboundEmail? + fun existsByMessageId(messageId: String): Boolean + fun existsByPropertyIdAndOtaBookingId(propertyId: UUID, otaBookingId: String): Boolean +} diff --git a/src/main/kotlin/com/android/trisolarisserver/models/booking/InboundEmail.kt b/src/main/kotlin/com/android/trisolarisserver/models/booking/InboundEmail.kt new file mode 100644 index 0000000..5ed62a5 --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/models/booking/InboundEmail.kt @@ -0,0 +1,68 @@ +package com.android.trisolarisserver.models.booking + +import com.android.trisolarisserver.models.property.Property +import jakarta.persistence.* +import java.time.OffsetDateTime +import java.util.UUID + +@Entity +@Table( + name = "inbound_email", + uniqueConstraints = [ + UniqueConstraint(columnNames = ["message_id"]), + UniqueConstraint(columnNames = ["property_id", "ota_booking_id"]) + ] +) +class InboundEmail( + @Id + @GeneratedValue + @Column(columnDefinition = "uuid") + val id: UUID? = null, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "property_id") + var property: Property? = null, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "booking_id") + var booking: Booking? = null, + + @Column(name = "ota_booking_id") + var otaBookingId: String? = null, + + @Column(name = "message_id") + var messageId: String? = null, + + @Column(name = "subject") + var subject: String? = null, + + @Column(name = "from_address") + var fromAddress: String? = null, + + @Column(name = "received_at", columnDefinition = "timestamptz") + var receivedAt: OffsetDateTime? = null, + + @Column(name = "status", nullable = false) + @Enumerated(EnumType.STRING) + var status: InboundEmailStatus = InboundEmailStatus.PENDING, + + @Column(name = "raw_pdf_path") + var rawPdfPath: String? = null, + + @Column(name = "extracted_data", columnDefinition = "jsonb") + var extractedData: String? = null, + + @Column(name = "processed_at", columnDefinition = "timestamptz") + var processedAt: OffsetDateTime? = null, + + @Column(name = "created_at", nullable = false, columnDefinition = "timestamptz") + val createdAt: OffsetDateTime = OffsetDateTime.now() +) + +enum class InboundEmailStatus { + PENDING, + CREATED, + CANCELLED, + SKIPPED, + ERROR +} diff --git a/src/main/kotlin/com/android/trisolarisserver/models/property/Property.kt b/src/main/kotlin/com/android/trisolarisserver/models/property/Property.kt index 459452c..a807eff 100644 --- a/src/main/kotlin/com/android/trisolarisserver/models/property/Property.kt +++ b/src/main/kotlin/com/android/trisolarisserver/models/property/Property.kt @@ -34,6 +34,14 @@ class Property( @Column(name = "is_active", nullable = false) var active: Boolean = true, + @ElementCollection(fetch = FetchType.EAGER) + @CollectionTable( + name = "property_email_alias", + joinColumns = [JoinColumn(name = "property_id")] + ) + @Column(name = "alias", nullable = false) + var emailAliases: MutableSet = mutableSetOf(), + @Column(name = "created_at", nullable = false, columnDefinition = "timestamptz") val createdAt: OffsetDateTime = OffsetDateTime.now() ) diff --git a/src/main/kotlin/com/android/trisolarisserver/service/EmailIngestionService.kt b/src/main/kotlin/com/android/trisolarisserver/service/EmailIngestionService.kt new file mode 100644 index 0000000..dea7e94 --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/service/EmailIngestionService.kt @@ -0,0 +1,298 @@ +package com.android.trisolarisserver.service + +import com.android.trisolarisserver.component.EmailStorage +import com.android.trisolarisserver.component.LlamaClient +import com.android.trisolarisserver.db.repo.BookingRepo +import com.android.trisolarisserver.db.repo.GuestRepo +import com.android.trisolarisserver.db.repo.InboundEmailRepo +import com.android.trisolarisserver.models.booking.Booking +import com.android.trisolarisserver.models.booking.BookingStatus +import com.android.trisolarisserver.models.booking.Guest +import com.android.trisolarisserver.models.booking.InboundEmail +import com.android.trisolarisserver.models.booking.InboundEmailStatus +import com.android.trisolarisserver.models.property.Property +import com.android.trisolarisserver.repo.PropertyRepo +import com.fasterxml.jackson.databind.ObjectMapper +import jakarta.mail.Flags +import jakarta.mail.Folder +import jakarta.mail.Message +import jakarta.mail.Multipart +import jakarta.mail.Session +import jakarta.mail.Store +import jakarta.mail.search.FlagTerm +import org.springframework.beans.factory.annotation.Value +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Component +import java.time.OffsetDateTime +import java.time.ZoneId +import java.util.Properties +import java.util.concurrent.atomic.AtomicBoolean +import java.security.MessageDigest + +@Component +class EmailIngestionService( + private val propertyRepo: PropertyRepo, + private val inboundEmailRepo: InboundEmailRepo, + private val bookingRepo: BookingRepo, + private val guestRepo: GuestRepo, + private val llamaClient: LlamaClient, + private val emailStorage: EmailStorage, + private val objectMapper: ObjectMapper, + @Value("\${mail.imap.host}") + private val host: String, + @Value("\${mail.imap.port:993}") + private val port: Int, + @Value("\${mail.imap.username}") + private val username: String, + @Value("\${mail.imap.password}") + private val password: String, + @Value("\${mail.imap.protocol:imaps}") + private val protocol: String +) { + private val running = AtomicBoolean(false) + @Value("\${mail.imap.enabled:false}") + private val enabled: Boolean = false + + @Scheduled(fixedDelayString = "\${mail.imap.pollMs:60000}") + fun pollInbox() { + if (!enabled) return + if (!running.compareAndSet(false, true)) return + try { + val session = Session.getInstance(Properties()) + val store: Store = session.getStore(protocol) + store.connect(host, port, username, password) + val inbox = store.getFolder("INBOX") + inbox.open(Folder.READ_WRITE) + + val unseen = inbox.search(FlagTerm(Flags(Flags.Flag.SEEN), false)) + for (message in unseen) { + processMessage(message) + message.setFlag(Flags.Flag.SEEN, true) + } + inbox.close(true) + store.close() + } catch (_: Exception) { + // swallow to keep scheduler alive + } finally { + running.set(false) + } + } + + private fun processMessage(message: Message) { + val messageId = buildMessageId(message) + if (messageId != null && inboundEmailRepo.existsByMessageId(messageId)) { + return + } + + val subject = message.subject + val from = message.from?.firstOrNull()?.toString() + val receivedAt = message.receivedDate?.toInstant()?.atOffset(OffsetDateTime.now().offset) + val body = extractText(message) + val property = matchProperty(subject, body) + + val inbound = InboundEmail( + property = property, + messageId = messageId, + subject = subject, + fromAddress = from, + receivedAt = receivedAt + ) + + inbound.rawPdfPath = emailStorage.storePdf(property?.id, messageId, subject, body) + inboundEmailRepo.save(inbound) + + if (property == null) { + inbound.status = InboundEmailStatus.SKIPPED + inbound.processedAt = OffsetDateTime.now() + inboundEmailRepo.save(inbound) + return + } + + val extracted = extractBookingDetails(body) + inbound.extractedData = objectMapper.writeValueAsString(extracted) + + val otaBookingId = extracted["otaBookingId"]?.takeIf { !it.contains("NONE", true) } + if (!otaBookingId.isNullOrBlank() && + inboundEmailRepo.existsByPropertyIdAndOtaBookingId(property.id!!, otaBookingId) + ) { + inbound.status = InboundEmailStatus.SKIPPED + inbound.processedAt = OffsetDateTime.now() + inboundEmailRepo.save(inbound) + return + } + + inbound.otaBookingId = otaBookingId + inboundEmailRepo.save(inbound) + + val isCancel = extracted["isCancel"]?.contains("YES", ignoreCase = true) == true + if (isCancel) { + if (otaBookingId.isNullOrBlank()) { + inbound.status = InboundEmailStatus.SKIPPED + inbound.processedAt = OffsetDateTime.now() + inboundEmailRepo.save(inbound) + return + } + val booking = bookingRepo.findByPropertyIdAndSourceBookingId(property.id!!, otaBookingId) + if (booking != null) { + booking.status = BookingStatus.CANCELLED + bookingRepo.save(booking) + inbound.booking = booking + } + inbound.status = InboundEmailStatus.CANCELLED + inbound.processedAt = OffsetDateTime.now() + inboundEmailRepo.save(inbound) + return + } + + val sourceBookingId = otaBookingId ?: "email:$messageId" + if (bookingRepo.existsByPropertyIdAndSourceBookingId(property.id!!, sourceBookingId)) { + inbound.status = InboundEmailStatus.SKIPPED + inbound.processedAt = OffsetDateTime.now() + inboundEmailRepo.save(inbound) + return + } + + val guest = resolveGuest(property, extracted) + val booking = createBooking(property, guest, extracted, sourceBookingId) + inbound.booking = booking + inbound.status = InboundEmailStatus.CREATED + inbound.processedAt = OffsetDateTime.now() + inboundEmailRepo.save(inbound) + } + + private fun extractBookingDetails(body: String): Map { + val results = linkedMapOf() + results["isCancel"] = llamaClient.askText(body, "Is this a cancellation email? Answer YES or NO only.") + results["otaBookingId"] = llamaClient.askText(body, "OTA BOOKING ID? Reply only the ID or NONE.") + results["checkinDate"] = llamaClient.askText(body, "CHECKIN DATE? Reply YYYY-MM-DD or NONE.") + results["checkoutDate"] = llamaClient.askText(body, "CHECKOUT DATE? Reply YYYY-MM-DD or NONE.") + results["nights"] = llamaClient.askText(body, "NIGHTS? Reply number or NONE.") + results["amount"] = llamaClient.askText(body, "TOTAL AMOUNT? Reply number only or NONE.") + results["roomRateWithoutGst"] = llamaClient.askText(body, "ROOM RATE WITHOUT GST? Reply number only or NONE.") + results["roomRateWithGst"] = llamaClient.askText(body, "ROOM RATE WITH GST? Reply number only or NONE.") + results["commissionIncludingGst"] = llamaClient.askText(body, "COMMISSION INCLUDING GST? Reply number only or NONE.") + results["guestName"] = llamaClient.askText(body, "GUEST NAME? Reply name or NONE.") + results["guestPhone"] = llamaClient.askText(body, "GUEST PHONE? Reply number or NONE.") + results["propertyName"] = llamaClient.askText(body, "PROPERTY NAME? Reply only name or NONE.") + results["propertyAddress"] = llamaClient.askText(body, "PROPERTY ADDRESS? Reply only address or NONE.") + results["roomTypesRaw"] = llamaClient.askText( + body, + "Room wise Payment Breakup. Return ONLY JSON. Include all dates." + ) + results["policy"] = llamaClient.askText(body, "CANCELLATION POLICY? Reply short text or NONE.") + results["otaSource"] = llamaClient.askText(body, "OTA SOURCE? Reply only name or NONE.") + + val source = results["otaSource"] ?: "" + if (source.contains("mmt", ignoreCase = true) || source.contains("make my trip", ignoreCase = true) || + source.contains("makemytrip", ignoreCase = true) + ) { + results["propertyGrossCharges"] = + llamaClient.askText(body, "PROPERTY GROSS CHARGES? Reply number only or NONE.") + results["payableToProperty"] = + llamaClient.askText(body, "PAYABLE TO PROPERTY? Reply number only or NONE.") + results["taxDeduction"] = + llamaClient.askText(body, "TAX DEDUCTION (7+8)? Reply number only or NONE.") + } + return results + } + + private fun resolveGuest(property: Property, extracted: Map): Guest { + val phone = extracted["guestPhone"]?.takeIf { !it.contains("NONE", true) }?.trim() + if (!phone.isNullOrBlank()) { + val existing = guestRepo.findByOrgIdAndPhoneE164(property.org.id!!, phone) + if (existing != null) return existing + } + val guest = Guest( + org = property.org, + phoneE164 = phone, + name = extracted["guestName"]?.takeIf { !it.contains("NONE", true) } + ) + return guestRepo.save(guest) + } + + private fun createBooking( + property: Property, + guest: Guest, + extracted: Map, + sourceBookingId: String + ): Booking { + val zone = ZoneId.of(property.timezone) + val checkin = parsedDate(extracted["checkinDate"], zone) + val checkout = parsedDate(extracted["checkoutDate"], zone) + val booking = Booking( + property = property, + primaryGuest = guest, + status = BookingStatus.OPEN, + source = extracted["otaSource"]?.takeIf { !it.contains("NONE", true) } ?: "OTA", + sourceBookingId = sourceBookingId, + expectedCheckinAt = checkin, + expectedCheckoutAt = checkout + ) + return bookingRepo.save(booking) + } + + private fun buildMessageId(message: Message): String? { + val header = message.getHeader("Message-ID")?.firstOrNull() + if (!header.isNullOrBlank()) return header + val from = message.from?.firstOrNull()?.toString() ?: "" + val subject = message.subject ?: "" + val received = message.receivedDate?.time?.toString() ?: "" + val raw = "$from|$subject|$received" + if (raw.isBlank()) return null + val digest = MessageDigest.getInstance("SHA-256").digest(raw.toByteArray()) + return digest.joinToString("") { "%02x".format(it) } + } + + private fun parsedDate(value: String?, zone: ZoneId): OffsetDateTime? { + if (value.isNullOrBlank() || value.contains("NONE", true)) return null + return try { + val localDate = java.time.LocalDate.parse(value.trim()) + localDate.atTime(12, 0).atZone(zone).toOffsetDateTime() + } catch (_: Exception) { + null + } + } + + private fun matchProperty(subject: String?, body: String): Property? { + val haystack = "${subject ?: ""}\n$body".lowercase() + val properties = propertyRepo.findAll() + val matches = properties.filter { property -> + val aliases = mutableSetOf() + aliases.add(property.name) + aliases.add(property.code) + aliases.addAll(property.emailAliases) + aliases.any { alias -> alias.isNotBlank() && haystack.contains(alias.lowercase()) } + } + return if (matches.size == 1) matches.first() else null + } + + private fun extractText(message: Message): String { + return try { + val content = message.content + when (content) { + is String -> content + is Multipart -> extractFromMultipart(content) + else -> content.toString() + } + } catch (_: Exception) { + "" + } + } + + private fun extractFromMultipart(multipart: Multipart): String { + var text: String? = null + var html: String? = null + for (i in 0 until multipart.count) { + val part = multipart.getBodyPart(i) + if (part.isMimeType("text/plain")) { + text = part.content.toString() + } else if (part.isMimeType("text/html")) { + html = part.content.toString() + } else if (part.content is Multipart) { + val nested = extractFromMultipart(part.content as Multipart) + if (nested.isNotBlank()) text = nested + } + } + return text ?: html?.replace(Regex("<[^>]*>"), " ") ?: "" + } +} diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties index dead37a..7321c3d 100644 --- a/src/main/resources/application-dev.properties +++ b/src/main/resources/application-dev.properties @@ -1 +1,2 @@ spring.datasource.url=jdbc:postgresql://192.168.1.53:5432/trisolaris +ai.llama.baseUrl=https://ai.hoteltrisolaris.in/v1/chat/completions diff --git a/src/main/resources/application-prod.properties b/src/main/resources/application-prod.properties index 863c748..678bf54 100644 --- a/src/main/resources/application-prod.properties +++ b/src/main/resources/application-prod.properties @@ -1 +1,2 @@ -spring.datasource.url=jdbc:postgresql://localhost:5432/trisolaris \ No newline at end of file +spring.datasource.url=jdbc:postgresql://localhost:5432/trisolaris +ai.llama.baseUrl=http://localhost:8089/v1/chat/completions diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 0b40639..11b9839 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -9,4 +9,11 @@ storage.documents.root=/home/androidlover5842/docs storage.documents.publicBaseUrl=https://api.hoteltrisolaris.in storage.documents.tokenSecret=change-me storage.documents.tokenTtlSeconds=300 -ai.llama.baseUrl=https://ai.hoteltrisolaris.in/v1/chat/completions +storage.emails.root=/home/androidlover5842/docs/emails +mail.imap.host=localhost +mail.imap.port=993 +mail.imap.protocol=imaps +mail.imap.username= +mail.imap.password= +mail.imap.pollMs=60000 +mail.imap.enabled=false