more codes
This commit is contained in:
251
AGENTS.md
251
AGENTS.md
@@ -1,19 +1,19 @@
|
|||||||
PROJECT CONTEXT / SYSTEM BRIEF
|
PROJECT CONTEXT / SYSTEM BRIEF
|
||||||
|
|
||||||
This is a hotel-grade Property Management System (PMS) being rebuilt from scratch.
|
This is a hotel-grade Property Management System (PMS) being rebuilt from scratch.
|
||||||
This AGENTS file captures both the product rules you gave and my current understanding of
|
This AGENTS file captures the product rules + current codebase state.
|
||||||
the TrisolarisServer codebase as of the last read, so future sessions can resume accurately.
|
|
||||||
|
|
||||||
Tech stack
|
Tech stack
|
||||||
- Spring Boot monolith
|
- Spring Boot monolith
|
||||||
- Kotlin only
|
- Kotlin only
|
||||||
- JPA / Hibernate
|
- JPA / Hibernate
|
||||||
- PostgreSQL
|
- PostgreSQL
|
||||||
- No Flyway for now, schema via JPA during development (Flyway is present in deps but disabled)
|
- No Flyway for now, schema via JPA during development (Flyway deps present but disabled)
|
||||||
- Single API domain api.hoteltrisolaris.in
|
- Single API domain api.hoteltrisolaris.in
|
||||||
- Android app and future website consume the same APIs
|
- Android app and future website consume the same APIs
|
||||||
|
|
||||||
This replaces a very old Firestore-based Android app. Old code exists only to understand behaviour. Do not reuse or mirror old models.
|
Legacy note
|
||||||
|
Old Firestore Android app exists only to understand behavior. Do not reuse old models.
|
||||||
|
|
||||||
CORE DESIGN PRINCIPLES
|
CORE DESIGN PRINCIPLES
|
||||||
|
|
||||||
@@ -52,76 +52,16 @@ Property access via PropertyUser
|
|||||||
Roles are per property
|
Roles are per property
|
||||||
Every API call must enforce property membership
|
Every API call must enforce property membership
|
||||||
|
|
||||||
CURRENT DOMAIN MODEL ALREADY CREATED
|
IMMUTABLE RULES
|
||||||
|
|
||||||
Organization
|
|
||||||
Property
|
|
||||||
AppUser
|
|
||||||
PropertyUser with roles
|
|
||||||
RoomType
|
|
||||||
Room
|
|
||||||
Booking
|
|
||||||
Guest
|
|
||||||
RoomStay
|
|
||||||
|
|
||||||
These entities already exist in Kotlin. Do not redesign unless explicitly asked.
|
|
||||||
|
|
||||||
WHAT THE SYSTEM MUST SUPPORT
|
|
||||||
|
|
||||||
Operational behaviour
|
|
||||||
- Staff sees exact room numbers free, occupied, checkout today
|
|
||||||
- Different rates for same room type
|
|
||||||
- Multiple rooms today and fewer tomorrow under the same booking
|
|
||||||
- Room changes without data loss
|
|
||||||
|
|
||||||
Financial behaviour
|
|
||||||
- Advance payments, partial payments, refunds
|
|
||||||
- PayU integration for QR and payment links
|
|
||||||
- Payment status via webhooks
|
|
||||||
- Clear source and destination of money
|
|
||||||
- Staff-wise collection tracking
|
|
||||||
|
|
||||||
Website integration
|
|
||||||
- Website reads live availability from PMS
|
|
||||||
- Website creates booking intents
|
|
||||||
- No inventory sync jobs
|
|
||||||
- Rate plans like DIRECT, WALKIN, OTA are snapshotted at booking time
|
|
||||||
|
|
||||||
Realtime
|
|
||||||
- Firestore-like realtime behaviour
|
|
||||||
- WebSocket or SSE for room board and payment updates
|
|
||||||
- Push notifications later via FCM
|
|
||||||
|
|
||||||
Infrastructure
|
|
||||||
- Nginx reverse proxy
|
|
||||||
- Single domain with multiple paths
|
|
||||||
- Database is never exposed publicly
|
|
||||||
|
|
||||||
IMPORTANT RULES FOR YOU
|
|
||||||
|
|
||||||
- Use Kotlin only
|
- Use Kotlin only
|
||||||
- Follow existing package structure
|
- Follow existing package structure
|
||||||
- No speculative features
|
- No speculative features
|
||||||
- No premature microservices
|
- No premature microservices
|
||||||
- Flyway must remain disabled during development. Do not introduce or modify Flyway migrations unless explicitly instructed after schema stabilization.
|
- Flyway must remain disabled during development. Do not introduce or modify Flyway migrations unless explicitly instructed after schema stabilization.
|
||||||
- Propose schema or API changes before coding if unsure
|
- Canonical staff roles: ADMIN, MANAGER, STAFF, HOUSEKEEPING, FINANCE (other roles may exist but must not be depended on)
|
||||||
- Money logic must be explicit and auditable
|
- Booking is a lifecycle container only. Booking does not own rooms or money. Occupancy via RoomStay. Billing via Charge/Payment ledgers.
|
||||||
- Canonical staff roles for now are ADMIN, MANAGER, STAFF, HOUSEKEEPING, FINANCE. Other roles may exist later but must not be depended on unless asked.
|
- Realtime must emit derived events only (no raw entity subscriptions)
|
||||||
- Booking is a lifecycle container only. Booking does not own rooms or money. Occupancy is only via RoomStay. Billing is only via Charge and Payment ledgers.
|
|
||||||
- Realtime features must emit derived domain events only. Clients must never subscribe to raw entity state or database changes.
|
|
||||||
|
|
||||||
HOW YOU SHOULD WORK
|
|
||||||
|
|
||||||
- Read the entire repository before making changes
|
|
||||||
- Work file by file
|
|
||||||
- Prefer small focused changes
|
|
||||||
- Ask before touching auth or payment logic
|
- Ask before touching auth or payment logic
|
||||||
- Assume this will run in real production hotels
|
|
||||||
|
|
||||||
FIRST TASK
|
|
||||||
|
|
||||||
Do nothing until asked.
|
|
||||||
Likely upcoming tasks include room board API, charge ledger, payment and PayU webhook flow, booking check-in transaction.
|
|
||||||
|
|
||||||
===============================================================================
|
===============================================================================
|
||||||
CURRENT CODEBASE UNDERSTANDING (TrisolarisServer)
|
CURRENT CODEBASE UNDERSTANDING (TrisolarisServer)
|
||||||
@@ -129,119 +69,76 @@ CURRENT CODEBASE UNDERSTANDING (TrisolarisServer)
|
|||||||
|
|
||||||
Repository
|
Repository
|
||||||
- Root: /home/androidlover5842/IdeaProjects/TrisolarisServer
|
- Root: /home/androidlover5842/IdeaProjects/TrisolarisServer
|
||||||
- Language: Kotlin only (Spring Boot 4, JPA)
|
- Entry: src/main/kotlin/com/android/trisolarisserver/TrisolarisServerApplication.kt
|
||||||
- Entry point: src/main/kotlin/com/android/trisolarisserver/TrisolarisServerApplication.kt
|
- Scheduling enabled (@EnableScheduling)
|
||||||
- Active controller layer is minimal (Rooms.kt is stubbed/commented)
|
|
||||||
|
|
||||||
Gradle
|
Security/Auth
|
||||||
- build.gradle.kts uses:
|
- Firebase Admin auth for every request, Firebase UID required.
|
||||||
- Spring Boot 4.0.1
|
- Security filter verifies token and maps to MyPrincipal(userId, firebaseUid).
|
||||||
- Kotlin 2.2.21 (kotlin("jvm"), kotlin("plugin.spring"), kotlin("plugin.jpa"))
|
- Endpoints: /auth/verify and /auth/me.
|
||||||
- Java toolchain 19
|
|
||||||
- JPA, WebMVC, Validation, Security, WebSocket, Flyway (dep), Postgres
|
|
||||||
- Flyway is disabled in application.properties
|
|
||||||
|
|
||||||
Configuration
|
Domain entities
|
||||||
- src/main/resources/application.properties
|
- Organization: id, name, emailAliases
|
||||||
- spring.jpa.hibernate.ddl-auto=update
|
- Property: org, code, name, addressText, timezone, currency, active, emailAddresses, otaAliases
|
||||||
- spring.jpa.open-in-view=false
|
- AppUser: org, firebaseUid, phoneE164, name, disabled
|
||||||
- flyway.enabled=false
|
- PropertyUser: roles per property
|
||||||
- application-dev.properties -> jdbc:postgresql://192.168.1.53:5432/trisolaris
|
- Role enum includes ADMIN, MANAGER, STAFF, HOUSEKEEPING, FINANCE, GUIDE, SUPERVISOR, AGENT (but only canonical roles should be used)
|
||||||
- application-prod.properties -> jdbc:postgresql://localhost:5432/trisolaris
|
- RoomType: code/name/occupancy + otaAliases
|
||||||
- DB password via env: DB_PASSWORD
|
- Room: roomNumber, floor, hasNfc, active, maintenance, notes
|
||||||
|
- Booking: status, source/sourceBookingId, expected check-in/out, emailAuditPdfUrl
|
||||||
Current packages and code
|
|
||||||
|
|
||||||
com.android.trisolarisserver.component
|
|
||||||
- PropertyAccess
|
|
||||||
- requireMember(propertyId, userId) -> checks PropertyUserRepo
|
|
||||||
- requireAnyRole(propertyId, userId, roles) -> checks roles
|
|
||||||
|
|
||||||
com.android.trisolarisserver.db.repo
|
|
||||||
- PropertyUserRepo
|
|
||||||
- existsByIdPropertyIdAndIdUserId(...)
|
|
||||||
- hasAnyRole(...) via JPQL joining property_user_role
|
|
||||||
- RoomRepo
|
|
||||||
- findFreeRooms(propertyId): active, not maintenance, no open RoomStay
|
|
||||||
- findOccupiedRooms(propertyId): rooms with active RoomStay
|
|
||||||
|
|
||||||
com.android.trisolarisserver.controller
|
|
||||||
- Rooms.kt: placeholder, no active endpoints yet
|
|
||||||
|
|
||||||
Entity model (current Kotlin entities)
|
|
||||||
- Organization
|
|
||||||
- id (uuid), name, createdAt
|
|
||||||
- Property
|
|
||||||
- id (uuid)
|
|
||||||
- org (Organization)
|
|
||||||
- code, name, timezone, currency
|
|
||||||
- active, createdAt
|
|
||||||
- AppUser
|
|
||||||
- id (uuid)
|
|
||||||
- org (Organization)
|
|
||||||
- firebaseUid, phoneE164, name
|
|
||||||
- disabled, createdAt
|
|
||||||
- PropertyUser
|
|
||||||
- composite key PropertyUserId (propertyId, userId)
|
|
||||||
- property (Property), user (AppUser)
|
|
||||||
- roles (ElementCollection of Role)
|
|
||||||
- Role enum
|
|
||||||
- ADMIN, MANAGER, STAFF, HOUSEKEEPING, FINANCE, GUIDE, SUPERVISOR, AGENT
|
|
||||||
- RoomType
|
|
||||||
- id (uuid)
|
|
||||||
- property (Property)
|
|
||||||
- code, name, baseOccupancy, maxOccupancy, createdAt
|
|
||||||
- Room
|
|
||||||
- id (uuid)
|
|
||||||
- property (Property)
|
|
||||||
- roomType (RoomType)
|
|
||||||
- roomNumber, floor
|
|
||||||
- hasNfc, active, maintenance, notes
|
|
||||||
- Booking
|
|
||||||
- id (uuid)
|
|
||||||
- property (Property)
|
|
||||||
- primaryGuest (Guest)
|
|
||||||
- status (BookingStatus)
|
|
||||||
- source, sourceBookingId
|
|
||||||
- checkinAt, checkoutAt
|
|
||||||
- expectedCheckinAt, expectedCheckoutAt
|
|
||||||
- notes
|
|
||||||
- createdBy (AppUser)
|
|
||||||
- createdAt, updatedAt
|
|
||||||
- BookingStatus enum
|
|
||||||
- OPEN, CHECKED_IN, CHECKED_OUT, CANCELLED, NO_SHOW
|
|
||||||
- Guest
|
- Guest
|
||||||
- id (uuid)
|
|
||||||
- org (Organization)
|
|
||||||
- phoneE164, name, nationality
|
|
||||||
- addressText
|
|
||||||
- createdAt, updatedAt
|
|
||||||
- RoomStay
|
- RoomStay
|
||||||
- id (uuid)
|
- GuestDocument: files for guest/booking with AI-extracted data
|
||||||
- property (Property)
|
- InboundEmail: inbound mail audit (PDF + raw eml), extractedData, status
|
||||||
- booking (Booking)
|
|
||||||
- room (Room)
|
|
||||||
- fromAt, toAt (null = active occupancy)
|
|
||||||
- createdBy (AppUser)
|
|
||||||
- createdAt
|
|
||||||
|
|
||||||
Notes on schema vs migration file
|
Repos
|
||||||
- There is a Flyway migration file at src/main/resources/db/migration/V1__core.sql,
|
- Repos are under com.android.trisolarisserver.repo (note: not db.repo).
|
||||||
but Flyway is disabled. The SQL file does NOT match current Kotlin entities in
|
- Added repos for Booking, Guest, GuestDocument, InboundEmail.
|
||||||
multiple places (columns and tables differ). For now, JPA schema generation is
|
|
||||||
authoritative during development.
|
|
||||||
|
|
||||||
Gaps relative to target design (do not implement unless asked)
|
Key modules
|
||||||
- No Charge entity yet
|
|
||||||
- No Payment entity yet
|
|
||||||
- No ledger/derived views
|
|
||||||
- No API controllers/services for bookings, rooms, payments
|
|
||||||
- No auth filter or principal model wired (PropertyAccess expects userId)
|
|
||||||
- No WebSocket/SSE endpoints yet
|
|
||||||
|
|
||||||
Behavioral requirements to keep in mind when coding
|
Rooms / inventory
|
||||||
- Every domain object must include property scope
|
- /properties/{propertyId}/rooms
|
||||||
- Room availability derived from RoomStay toAt == null
|
- /properties/{propertyId}/rooms/board
|
||||||
- Room changes are new RoomStay + closing old
|
- /properties/{propertyId}/rooms/availability
|
||||||
- Charges and payments are append-only (never overwrite totals)
|
- /properties/{propertyId}/rooms/availability-range?from=YYYY-MM-DD&to=YYYY-MM-DD
|
||||||
- Clients send intent; server derives facts
|
Availability derived from RoomStay. Date range uses overlap [from, to).
|
||||||
|
|
||||||
|
Room types
|
||||||
|
- CRUD with otaAliases in DTOs.
|
||||||
|
|
||||||
|
Properties/Orgs
|
||||||
|
- Property create/update accept addressText, otaAliases, emailAddresses.
|
||||||
|
- Org create/get returns emailAliases.
|
||||||
|
|
||||||
|
Guest documents (files)
|
||||||
|
- POST /properties/{propertyId}/guests/{guestId}/documents (multipart + bookingId)
|
||||||
|
- GET list
|
||||||
|
- GET file (token or auth)
|
||||||
|
- Files stored under /home/androidlover5842/docs/{propertyId}/{guestId}/{bookingId}/
|
||||||
|
- AI extraction via llama.cpp with strict system prompt
|
||||||
|
|
||||||
|
Inbound email ingestion
|
||||||
|
- IMAP poller (1 min) with enable flag.
|
||||||
|
- Saves audit PDF + raw .eml to /home/androidlover5842/docs/emails
|
||||||
|
- Matches property via To/CC email first, then text aliases (name/code/address/otaAliases)
|
||||||
|
- AI extracts booking fields and creates/cancels Booking
|
||||||
|
- Booking gets emailAuditPdfUrl that points to /properties/{propertyId}/inbound-emails/{emailId}/file
|
||||||
|
|
||||||
|
AI integration
|
||||||
|
- Base URL configured per profile:
|
||||||
|
- dev: https://ai.hoteltrisolaris.in/v1/chat/completions
|
||||||
|
- prod: http://localhost:8089/v1/chat/completions
|
||||||
|
- LlamaClient uses strict system prompt: only visible text, no guessing.
|
||||||
|
- Read timeout 5 minutes.
|
||||||
|
|
||||||
|
Config
|
||||||
|
- application.properties: flyway disabled, storage paths, IMAP config, token secrets.
|
||||||
|
- storage.documents.publicBaseUrl + token secret/ttl.
|
||||||
|
- storage.emails.publicBaseUrl used for booking audit URL.
|
||||||
|
- mail.imap.enabled=false by default.
|
||||||
|
|
||||||
|
Notes / constraints
|
||||||
|
- API user creation removed; users are created by app; API only manages roles.
|
||||||
|
- Admin can assign ADMIN/MANAGER/STAFF/AGENT; Manager can assign STAFF/AGENT.
|
||||||
|
- Agents can only see free rooms.
|
||||||
|
|||||||
@@ -101,6 +101,18 @@ class EmailStorage(
|
|||||||
return path.toString()
|
return path.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun storeUploadedPdf(propertyId: UUID, originalName: String?, bytes: ByteArray): String {
|
||||||
|
val dir = Paths.get(rootPath, propertyId.toString(), "manual")
|
||||||
|
Files.createDirectories(dir)
|
||||||
|
val safeName = (originalName ?: UUID.randomUUID().toString()).replace(Regex("[^A-Za-z0-9._-]"), "_")
|
||||||
|
val fileName = "${safeName}_${OffsetDateTime.now().toEpochSecond()}.pdf"
|
||||||
|
val path = dir.resolve(fileName).normalize()
|
||||||
|
val tmp = dir.resolve("${fileName}.tmp").normalize()
|
||||||
|
Files.write(tmp, bytes)
|
||||||
|
atomicMove(tmp, path)
|
||||||
|
return path.toString()
|
||||||
|
}
|
||||||
|
|
||||||
private fun atomicMove(tmp: Path, target: Path) {
|
private fun atomicMove(tmp: Path, target: Path) {
|
||||||
try {
|
try {
|
||||||
Files.move(tmp, target, java.nio.file.StandardCopyOption.ATOMIC_MOVE, java.nio.file.StandardCopyOption.REPLACE_EXISTING)
|
Files.move(tmp, target, java.nio.file.StandardCopyOption.ATOMIC_MOVE, java.nio.file.StandardCopyOption.REPLACE_EXISTING)
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
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.awt.image.BufferedImage
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.Path
|
||||||
|
import java.nio.file.Paths
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
import java.util.UUID
|
||||||
|
import javax.imageio.ImageIO
|
||||||
|
|
||||||
|
@Component
|
||||||
|
class RoomImageStorage(
|
||||||
|
@Value("\${storage.rooms.root:/home/androidlover5842/docs/rooms}")
|
||||||
|
private val rootPath: String
|
||||||
|
) {
|
||||||
|
fun store(propertyId: UUID, roomId: UUID, file: MultipartFile): StoredRoomImage {
|
||||||
|
val contentType = file.contentType ?: ""
|
||||||
|
if (!contentType.startsWith("image/")) {
|
||||||
|
throw IllegalArgumentException("Only image files are allowed")
|
||||||
|
}
|
||||||
|
val bytes = file.bytes
|
||||||
|
val originalName = file.originalFilename ?: UUID.randomUUID().toString()
|
||||||
|
val ext = extensionFor(contentType, originalName)
|
||||||
|
|
||||||
|
val dir = Paths.get(rootPath, propertyId.toString(), roomId.toString())
|
||||||
|
Files.createDirectories(dir)
|
||||||
|
val base = UUID.randomUUID().toString() + "_" + OffsetDateTime.now().toEpochSecond()
|
||||||
|
val originalPath = dir.resolve("$base.$ext")
|
||||||
|
val originalTmp = dir.resolve("$base.$ext.tmp")
|
||||||
|
Files.write(originalTmp, bytes)
|
||||||
|
atomicMove(originalTmp, originalPath)
|
||||||
|
|
||||||
|
val image = readImage(bytes)
|
||||||
|
?: throw IllegalArgumentException("Unsupported image")
|
||||||
|
val thumb = resize(image, 320)
|
||||||
|
val thumbExt = if (ext.lowercase() == "jpg") "jpg" else "png"
|
||||||
|
val thumbPath = dir.resolve("${base}_thumb.$thumbExt")
|
||||||
|
val thumbTmp = dir.resolve("${base}_thumb.$thumbExt.tmp")
|
||||||
|
ByteArrayInputStream(render(thumb, thumbExt)).use { input ->
|
||||||
|
Files.copy(input, thumbTmp)
|
||||||
|
}
|
||||||
|
atomicMove(thumbTmp, thumbPath)
|
||||||
|
|
||||||
|
return StoredRoomImage(
|
||||||
|
originalPath = originalPath.toString(),
|
||||||
|
thumbnailPath = thumbPath.toString(),
|
||||||
|
contentType = contentType,
|
||||||
|
sizeBytes = bytes.size.toLong()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readImage(bytes: ByteArray): BufferedImage? {
|
||||||
|
return ByteArrayInputStream(bytes).use { input -> ImageIO.read(input) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resize(input: BufferedImage, maxSize: Int): BufferedImage {
|
||||||
|
val width = input.width
|
||||||
|
val height = input.height
|
||||||
|
if (width <= maxSize && height <= maxSize) return input
|
||||||
|
val scale = if (width > height) maxSize.toDouble() / width else maxSize.toDouble() / height
|
||||||
|
val newW = (width * scale).toInt()
|
||||||
|
val newH = (height * scale).toInt()
|
||||||
|
val output = BufferedImage(newW, newH, BufferedImage.TYPE_INT_RGB)
|
||||||
|
val g = output.createGraphics()
|
||||||
|
g.drawImage(input, 0, 0, newW, newH, null)
|
||||||
|
g.dispose()
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun render(image: BufferedImage, format: String): ByteArray {
|
||||||
|
val out = java.io.ByteArrayOutputStream()
|
||||||
|
ImageIO.write(image, format, out)
|
||||||
|
return out.toByteArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extensionFor(contentType: String, filename: String): String {
|
||||||
|
return when {
|
||||||
|
contentType.contains("png", true) -> "png"
|
||||||
|
contentType.contains("jpeg", true) || contentType.contains("jpg", true) -> "jpg"
|
||||||
|
filename.contains(".") -> filename.substringAfterLast('.').lowercase()
|
||||||
|
else -> "png"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun atomicMove(tmp: Path, target: Path) {
|
||||||
|
try {
|
||||||
|
Files.move(tmp, target, java.nio.file.StandardCopyOption.ATOMIC_MOVE, java.nio.file.StandardCopyOption.REPLACE_EXISTING)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
Files.move(tmp, target, java.nio.file.StandardCopyOption.REPLACE_EXISTING)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class StoredRoomImage(
|
||||||
|
val originalPath: String,
|
||||||
|
val thumbnailPath: String,
|
||||||
|
val contentType: String,
|
||||||
|
val sizeBytes: Long
|
||||||
|
)
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package com.android.trisolarisserver.controller
|
||||||
|
|
||||||
|
import com.android.trisolarisserver.component.EmailStorage
|
||||||
|
import com.android.trisolarisserver.component.PropertyAccess
|
||||||
|
import com.android.trisolarisserver.db.repo.InboundEmailRepo
|
||||||
|
import com.android.trisolarisserver.models.booking.InboundEmail
|
||||||
|
import com.android.trisolarisserver.models.booking.InboundEmailStatus
|
||||||
|
import com.android.trisolarisserver.models.property.Role
|
||||||
|
import com.android.trisolarisserver.repo.PropertyRepo
|
||||||
|
import com.android.trisolarisserver.security.MyPrincipal
|
||||||
|
import com.android.trisolarisserver.service.EmailIngestionService
|
||||||
|
import org.apache.pdfbox.pdmodel.PDDocument
|
||||||
|
import org.apache.pdfbox.text.PDFTextStripper
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
|
import org.springframework.web.bind.annotation.ResponseStatus
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import org.springframework.web.multipart.MultipartFile
|
||||||
|
import org.springframework.web.server.ResponseStatusException
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/properties/{propertyId}/inbound-emails")
|
||||||
|
class InboundEmailManual(
|
||||||
|
private val propertyAccess: PropertyAccess,
|
||||||
|
private val propertyRepo: PropertyRepo,
|
||||||
|
private val inboundEmailRepo: InboundEmailRepo,
|
||||||
|
private val emailStorage: EmailStorage,
|
||||||
|
private val emailIngestionService: EmailIngestionService
|
||||||
|
) {
|
||||||
|
|
||||||
|
@PostMapping("/manual")
|
||||||
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
|
fun uploadManualPdf(
|
||||||
|
@PathVariable propertyId: UUID,
|
||||||
|
@AuthenticationPrincipal principal: MyPrincipal?,
|
||||||
|
@RequestParam("file") file: MultipartFile
|
||||||
|
): ManualInboundResponse {
|
||||||
|
requirePrincipal(principal)
|
||||||
|
propertyAccess.requireMember(propertyId, principal!!.userId)
|
||||||
|
propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN, Role.MANAGER)
|
||||||
|
|
||||||
|
if (file.isEmpty) {
|
||||||
|
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "File is empty")
|
||||||
|
}
|
||||||
|
val contentType = file.contentType
|
||||||
|
if (contentType != null && !contentType.equals("application/pdf", ignoreCase = true)) {
|
||||||
|
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Only PDF is supported")
|
||||||
|
}
|
||||||
|
|
||||||
|
val property = propertyRepo.findById(propertyId).orElseThrow {
|
||||||
|
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
val bytes = file.bytes
|
||||||
|
val pdfPath = emailStorage.storeUploadedPdf(propertyId, file.originalFilename, bytes)
|
||||||
|
val text = extractPdfText(bytes)
|
||||||
|
|
||||||
|
val inbound = InboundEmail(
|
||||||
|
property = property,
|
||||||
|
messageId = "manual-${UUID.randomUUID()}",
|
||||||
|
subject = file.originalFilename ?: "manual-upload",
|
||||||
|
fromAddress = null,
|
||||||
|
receivedAt = OffsetDateTime.now(),
|
||||||
|
status = InboundEmailStatus.PENDING,
|
||||||
|
rawPdfPath = pdfPath
|
||||||
|
)
|
||||||
|
|
||||||
|
inboundEmailRepo.save(inbound)
|
||||||
|
emailIngestionService.ingestManualPdf(property, inbound, text)
|
||||||
|
return ManualInboundResponse(inboundId = inbound.id!!)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractPdfText(bytes: ByteArray): String {
|
||||||
|
return try {
|
||||||
|
PDDocument.load(bytes).use { doc ->
|
||||||
|
PDFTextStripper().getText(doc)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requirePrincipal(principal: MyPrincipal?) {
|
||||||
|
if (principal == null) {
|
||||||
|
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ManualInboundResponse(
|
||||||
|
val inboundId: UUID
|
||||||
|
)
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package com.android.trisolarisserver.controller
|
||||||
|
|
||||||
|
import com.android.trisolarisserver.component.PropertyAccess
|
||||||
|
import com.android.trisolarisserver.component.RoomImageStorage
|
||||||
|
import com.android.trisolarisserver.controller.dto.RoomImageResponse
|
||||||
|
import com.android.trisolarisserver.models.room.RoomImage
|
||||||
|
import com.android.trisolarisserver.models.property.Role
|
||||||
|
import com.android.trisolarisserver.repo.RoomImageRepo
|
||||||
|
import com.android.trisolarisserver.repo.RoomRepo
|
||||||
|
import com.android.trisolarisserver.security.MyPrincipal
|
||||||
|
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.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
|
import org.springframework.web.bind.annotation.ResponseStatus
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import org.springframework.web.multipart.MultipartFile
|
||||||
|
import org.springframework.web.server.ResponseStatusException
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.Paths
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/properties/{propertyId}/rooms/{roomId}/images")
|
||||||
|
class RoomImages(
|
||||||
|
private val propertyAccess: PropertyAccess,
|
||||||
|
private val roomRepo: RoomRepo,
|
||||||
|
private val roomImageRepo: RoomImageRepo,
|
||||||
|
private val storage: RoomImageStorage,
|
||||||
|
@org.springframework.beans.factory.annotation.Value("\${storage.rooms.publicBaseUrl}")
|
||||||
|
private val publicBaseUrl: String
|
||||||
|
) {
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
fun list(
|
||||||
|
@PathVariable propertyId: UUID,
|
||||||
|
@PathVariable roomId: UUID,
|
||||||
|
@AuthenticationPrincipal principal: MyPrincipal?
|
||||||
|
): List<RoomImageResponse> {
|
||||||
|
requirePrincipal(principal)
|
||||||
|
propertyAccess.requireMember(propertyId, principal!!.userId)
|
||||||
|
ensureRoom(propertyId, roomId)
|
||||||
|
return roomImageRepo.findByRoomIdOrderByCreatedAtDesc(roomId)
|
||||||
|
.map { it.toResponse(publicBaseUrl) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
|
fun upload(
|
||||||
|
@PathVariable propertyId: UUID,
|
||||||
|
@PathVariable roomId: UUID,
|
||||||
|
@AuthenticationPrincipal principal: MyPrincipal?,
|
||||||
|
@RequestParam("file") file: MultipartFile
|
||||||
|
): RoomImageResponse {
|
||||||
|
requirePrincipal(principal)
|
||||||
|
propertyAccess.requireMember(propertyId, principal!!.userId)
|
||||||
|
propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN, Role.MANAGER)
|
||||||
|
val room = ensureRoom(propertyId, roomId)
|
||||||
|
|
||||||
|
if (file.isEmpty) {
|
||||||
|
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "File is empty")
|
||||||
|
}
|
||||||
|
val stored = try {
|
||||||
|
storage.store(propertyId, roomId, file)
|
||||||
|
} catch (ex: IllegalArgumentException) {
|
||||||
|
throw ResponseStatusException(HttpStatus.BAD_REQUEST, ex.message ?: "Invalid image")
|
||||||
|
}
|
||||||
|
|
||||||
|
val image = RoomImage(
|
||||||
|
property = room.property,
|
||||||
|
room = room,
|
||||||
|
originalPath = stored.originalPath,
|
||||||
|
thumbnailPath = stored.thumbnailPath,
|
||||||
|
contentType = stored.contentType,
|
||||||
|
sizeBytes = stored.sizeBytes
|
||||||
|
)
|
||||||
|
return roomImageRepo.save(image).toResponse(publicBaseUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{imageId}/file")
|
||||||
|
fun file(
|
||||||
|
@PathVariable propertyId: UUID,
|
||||||
|
@PathVariable roomId: UUID,
|
||||||
|
@PathVariable imageId: UUID,
|
||||||
|
@RequestParam(required = false, defaultValue = "full") size: String,
|
||||||
|
@AuthenticationPrincipal principal: MyPrincipal?
|
||||||
|
): ResponseEntity<FileSystemResource> {
|
||||||
|
requirePrincipal(principal)
|
||||||
|
propertyAccess.requireMember(propertyId, principal!!.userId)
|
||||||
|
ensureRoom(propertyId, roomId)
|
||||||
|
|
||||||
|
val image = roomImageRepo.findByIdAndRoomIdAndPropertyId(imageId, roomId, propertyId)
|
||||||
|
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Image not found")
|
||||||
|
val path = if (size.equals("thumb", true)) image.thumbnailPath else image.originalPath
|
||||||
|
val file = Paths.get(path)
|
||||||
|
if (!Files.exists(file)) {
|
||||||
|
throw ResponseStatusException(HttpStatus.NOT_FOUND, "File missing")
|
||||||
|
}
|
||||||
|
val resource = FileSystemResource(file)
|
||||||
|
val type = image.contentType
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.contentType(MediaType.parseMediaType(type))
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"room-${imageId}.${file.fileName}\"")
|
||||||
|
.contentLength(resource.contentLength())
|
||||||
|
.body(resource)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureRoom(propertyId: UUID, roomId: UUID): com.android.trisolarisserver.models.room.Room {
|
||||||
|
return roomRepo.findByIdAndPropertyId(roomId, propertyId)
|
||||||
|
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requirePrincipal(principal: MyPrincipal?) {
|
||||||
|
if (principal == null) {
|
||||||
|
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun RoomImage.toResponse(baseUrl: String): RoomImageResponse {
|
||||||
|
val id = id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Image id missing")
|
||||||
|
return RoomImageResponse(
|
||||||
|
id = id,
|
||||||
|
propertyId = property.id!!,
|
||||||
|
roomId = room.id!!,
|
||||||
|
url = "$baseUrl/properties/${property.id}/rooms/${room.id}/images/$id/file",
|
||||||
|
thumbnailUrl = "$baseUrl/properties/${property.id}/rooms/${room.id}/images/$id/file?size=thumb",
|
||||||
|
contentType = contentType,
|
||||||
|
sizeBytes = sizeBytes,
|
||||||
|
createdAt = createdAt.toString()
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.android.trisolarisserver.controller
|
package com.android.trisolarisserver.controller
|
||||||
|
|
||||||
import com.android.trisolarisserver.component.PropertyAccess
|
import com.android.trisolarisserver.component.PropertyAccess
|
||||||
|
import com.android.trisolarisserver.controller.dto.RoomAvailabilityRangeResponse
|
||||||
import com.android.trisolarisserver.controller.dto.RoomAvailabilityResponse
|
import com.android.trisolarisserver.controller.dto.RoomAvailabilityResponse
|
||||||
import com.android.trisolarisserver.controller.dto.RoomBoardResponse
|
import com.android.trisolarisserver.controller.dto.RoomBoardResponse
|
||||||
import com.android.trisolarisserver.controller.dto.RoomBoardStatus
|
import com.android.trisolarisserver.controller.dto.RoomBoardStatus
|
||||||
@@ -25,6 +26,8 @@ import org.springframework.web.bind.annotation.RequestMapping
|
|||||||
import org.springframework.web.bind.annotation.ResponseStatus
|
import org.springframework.web.bind.annotation.ResponseStatus
|
||||||
import org.springframework.web.bind.annotation.RestController
|
import org.springframework.web.bind.annotation.RestController
|
||||||
import org.springframework.web.server.ResponseStatusException
|
import org.springframework.web.server.ResponseStatusException
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.ZoneId
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@@ -106,6 +109,43 @@ class Rooms(
|
|||||||
}.sortedBy { it.roomTypeName }
|
}.sortedBy { it.roomTypeName }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/availability-range")
|
||||||
|
fun roomAvailabilityRange(
|
||||||
|
@PathVariable propertyId: UUID,
|
||||||
|
@AuthenticationPrincipal principal: MyPrincipal?,
|
||||||
|
@org.springframework.web.bind.annotation.RequestParam("from") from: String,
|
||||||
|
@org.springframework.web.bind.annotation.RequestParam("to") to: String
|
||||||
|
): List<RoomAvailabilityRangeResponse> {
|
||||||
|
requirePrincipal(principal)
|
||||||
|
propertyAccess.requireMember(propertyId, principal!!.userId)
|
||||||
|
|
||||||
|
val property = propertyRepo.findById(propertyId).orElseThrow {
|
||||||
|
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
val fromDate = parseDate(from)
|
||||||
|
val toDate = parseDate(to)
|
||||||
|
if (!toDate.isAfter(fromDate)) {
|
||||||
|
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid date range")
|
||||||
|
}
|
||||||
|
val zone = ZoneId.of(property.timezone)
|
||||||
|
val fromAt = fromDate.atStartOfDay(zone).toOffsetDateTime()
|
||||||
|
val toAt = toDate.atStartOfDay(zone).toOffsetDateTime()
|
||||||
|
|
||||||
|
val rooms = roomRepo.findByPropertyIdOrderByRoomNumber(propertyId)
|
||||||
|
val occupiedRoomIds = roomStayRepo.findOccupiedRoomIdsBetween(propertyId, fromAt, toAt).toHashSet()
|
||||||
|
|
||||||
|
val freeRooms = rooms.filter { it.active && !it.maintenance && !occupiedRoomIds.contains(it.id) }
|
||||||
|
val grouped = freeRooms.groupBy { it.roomType.name }
|
||||||
|
return grouped.entries.map { (typeName, roomList) ->
|
||||||
|
RoomAvailabilityRangeResponse(
|
||||||
|
roomTypeName = typeName,
|
||||||
|
freeRoomNumbers = roomList.map { it.roomNumber },
|
||||||
|
freeCount = roomList.size
|
||||||
|
)
|
||||||
|
}.sortedBy { it.roomTypeName }
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
@ResponseStatus(HttpStatus.CREATED)
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
fun createRoom(
|
fun createRoom(
|
||||||
@@ -182,6 +222,14 @@ class Rooms(
|
|||||||
val privileged = setOf(Role.ADMIN, Role.MANAGER, Role.STAFF, Role.HOUSEKEEPING, Role.FINANCE)
|
val privileged = setOf(Role.ADMIN, Role.MANAGER, Role.STAFF, Role.HOUSEKEEPING, Role.FINANCE)
|
||||||
return roles.none { it in privileged }
|
return roles.none { it in privileged }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun parseDate(value: String): LocalDate {
|
||||||
|
return try {
|
||||||
|
LocalDate.parse(value.trim())
|
||||||
|
} catch (_: Exception) {
|
||||||
|
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid date format")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Room.toRoomResponse(): RoomResponse {
|
private fun Room.toRoomResponse(): RoomResponse {
|
||||||
|
|||||||
@@ -25,6 +25,23 @@ data class RoomAvailabilityResponse(
|
|||||||
val freeRoomNumbers: List<Int>
|
val freeRoomNumbers: List<Int>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class RoomAvailabilityRangeResponse(
|
||||||
|
val roomTypeName: String,
|
||||||
|
val freeRoomNumbers: List<Int>,
|
||||||
|
val freeCount: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
data class RoomImageResponse(
|
||||||
|
val id: UUID,
|
||||||
|
val propertyId: UUID,
|
||||||
|
val roomId: UUID,
|
||||||
|
val url: String,
|
||||||
|
val thumbnailUrl: String,
|
||||||
|
val contentType: String,
|
||||||
|
val sizeBytes: Long,
|
||||||
|
val createdAt: String
|
||||||
|
)
|
||||||
|
|
||||||
enum class RoomBoardStatus {
|
enum class RoomBoardStatus {
|
||||||
FREE,
|
FREE,
|
||||||
OCCUPIED,
|
OCCUPIED,
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.android.trisolarisserver.models.room
|
||||||
|
|
||||||
|
import com.android.trisolarisserver.models.property.Property
|
||||||
|
import jakarta.persistence.*
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "room_image")
|
||||||
|
class RoomImage(
|
||||||
|
@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 = "room_id", nullable = false)
|
||||||
|
var room: Room,
|
||||||
|
|
||||||
|
@Column(name = "original_path", nullable = false)
|
||||||
|
var originalPath: String,
|
||||||
|
|
||||||
|
@Column(name = "thumbnail_path", nullable = false)
|
||||||
|
var thumbnailPath: String,
|
||||||
|
|
||||||
|
@Column(name = "content_type", nullable = false)
|
||||||
|
var contentType: String,
|
||||||
|
|
||||||
|
@Column(name = "size_bytes", nullable = false)
|
||||||
|
var sizeBytes: Long,
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false, columnDefinition = "timestamptz")
|
||||||
|
val createdAt: OffsetDateTime = OffsetDateTime.now()
|
||||||
|
)
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.android.trisolarisserver.repo
|
||||||
|
|
||||||
|
import com.android.trisolarisserver.models.room.RoomImage
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
interface RoomImageRepo : JpaRepository<RoomImage, UUID> {
|
||||||
|
fun findByRoomIdOrderByCreatedAtDesc(roomId: UUID): List<RoomImage>
|
||||||
|
fun findByIdAndRoomIdAndPropertyId(id: UUID, roomId: UUID, propertyId: UUID): RoomImage?
|
||||||
|
}
|
||||||
@@ -14,4 +14,17 @@ interface RoomStayRepo : JpaRepository<RoomStay, UUID> {
|
|||||||
and rs.toAt is null
|
and rs.toAt is null
|
||||||
""")
|
""")
|
||||||
fun findOccupiedRoomIds(@Param("propertyId") propertyId: UUID): List<UUID>
|
fun findOccupiedRoomIds(@Param("propertyId") propertyId: UUID): List<UUID>
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
select distinct rs.room.id
|
||||||
|
from RoomStay rs
|
||||||
|
where rs.property.id = :propertyId
|
||||||
|
and rs.fromAt < :toAt
|
||||||
|
and (rs.toAt is null or rs.toAt > :fromAt)
|
||||||
|
""")
|
||||||
|
fun findOccupiedRoomIdsBetween(
|
||||||
|
@Param("propertyId") propertyId: UUID,
|
||||||
|
@Param("fromAt") fromAt: java.time.OffsetDateTime,
|
||||||
|
@Param("toAt") toAt: java.time.OffsetDateTime
|
||||||
|
): List<UUID>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,57 +115,7 @@ class EmailIngestionService(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val extracted = extractBookingDetails(body)
|
handleExtracted(property, inbound, body, "email:$messageId")
|
||||||
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 emailUrl = "${publicBaseUrl}/properties/${property.id}/inbound-emails/${inbound.id}/file"
|
|
||||||
val booking = createBooking(property, guest, extracted, sourceBookingId, emailUrl)
|
|
||||||
inbound.booking = booking
|
|
||||||
inbound.status = InboundEmailStatus.CREATED
|
|
||||||
inbound.processedAt = OffsetDateTime.now()
|
|
||||||
inboundEmailRepo.save(inbound)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun extractBookingDetails(body: String): Map<String, String> {
|
private fun extractBookingDetails(body: String): Map<String, String> {
|
||||||
@@ -241,6 +191,65 @@ class EmailIngestionService(
|
|||||||
return bookingRepo.save(booking)
|
return bookingRepo.save(booking)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun ingestManualPdf(property: Property, inbound: InboundEmail, body: String) {
|
||||||
|
inboundEmailRepo.save(inbound)
|
||||||
|
handleExtracted(property, inbound, body, "manual:${inbound.id}")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleExtracted(property: Property, inbound: InboundEmail, body: String, fallbackKey: String) {
|
||||||
|
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 ?: fallbackKey
|
||||||
|
if (bookingRepo.existsByPropertyIdAndSourceBookingId(property.id!!, sourceBookingId)) {
|
||||||
|
inbound.status = InboundEmailStatus.SKIPPED
|
||||||
|
inbound.processedAt = OffsetDateTime.now()
|
||||||
|
inboundEmailRepo.save(inbound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val guest = resolveGuest(property, extracted)
|
||||||
|
val emailUrl = "${publicBaseUrl}/properties/${property.id}/inbound-emails/${inbound.id}/file"
|
||||||
|
val booking = createBooking(property, guest, extracted, sourceBookingId, emailUrl)
|
||||||
|
inbound.booking = booking
|
||||||
|
inbound.status = InboundEmailStatus.CREATED
|
||||||
|
inbound.processedAt = OffsetDateTime.now()
|
||||||
|
inboundEmailRepo.save(inbound)
|
||||||
|
}
|
||||||
|
|
||||||
private fun buildMessageId(message: Message): String? {
|
private fun buildMessageId(message: Message): String? {
|
||||||
val header = message.getHeader("Message-ID")?.firstOrNull()
|
val header = message.getHeader("Message-ID")?.firstOrNull()
|
||||||
if (!header.isNullOrBlank()) return header
|
if (!header.isNullOrBlank()) return header
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ storage.documents.tokenSecret=change-me
|
|||||||
storage.documents.tokenTtlSeconds=300
|
storage.documents.tokenTtlSeconds=300
|
||||||
storage.emails.root=/home/androidlover5842/docs/emails
|
storage.emails.root=/home/androidlover5842/docs/emails
|
||||||
storage.emails.publicBaseUrl=https://api.hoteltrisolaris.in
|
storage.emails.publicBaseUrl=https://api.hoteltrisolaris.in
|
||||||
|
storage.rooms.root=/home/androidlover5842/docs/rooms
|
||||||
|
storage.rooms.publicBaseUrl=https://api.hoteltrisolaris.in
|
||||||
mail.imap.host=localhost
|
mail.imap.host=localhost
|
||||||
mail.imap.port=993
|
mail.imap.port=993
|
||||||
mail.imap.protocol=imaps
|
mail.imap.protocol=imaps
|
||||||
|
|||||||
Reference in New Issue
Block a user