From 6b6d84e40ab41b39c0be72b22643905463befb51 Mon Sep 17 00:00:00 2001 From: androidlover5842 Date: Sat, 24 Jan 2026 22:22:37 +0530 Subject: [PATCH] more codes --- AGENTS.md | 251 ++++++------------ .../component/EmailStorage.kt | 12 + .../component/RoomImageStorage.kt | 103 +++++++ .../controller/InboundEmailManual.kt | 98 +++++++ .../trisolarisserver/controller/RoomImages.kt | 139 ++++++++++ .../trisolarisserver/controller/Rooms.kt | 48 ++++ .../controller/dto/RoomDtos.kt | 17 ++ .../trisolarisserver/models/room/RoomImage.kt | 38 +++ .../trisolarisserver/repo/RoomImageRepo.kt | 10 + .../trisolarisserver/repo/RoomStayRepo.kt | 13 + .../service/EmailIngestionService.kt | 111 ++++---- src/main/resources/application.properties | 2 + 12 files changed, 614 insertions(+), 228 deletions(-) create mode 100644 src/main/kotlin/com/android/trisolarisserver/component/RoomImageStorage.kt create mode 100644 src/main/kotlin/com/android/trisolarisserver/controller/InboundEmailManual.kt create mode 100644 src/main/kotlin/com/android/trisolarisserver/controller/RoomImages.kt create mode 100644 src/main/kotlin/com/android/trisolarisserver/models/room/RoomImage.kt create mode 100644 src/main/kotlin/com/android/trisolarisserver/repo/RoomImageRepo.kt diff --git a/AGENTS.md b/AGENTS.md index 82eea27..66f49fd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,19 +1,19 @@ PROJECT CONTEXT / SYSTEM BRIEF 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 -the TrisolarisServer codebase as of the last read, so future sessions can resume accurately. +This AGENTS file captures the product rules + current codebase state. Tech stack - Spring Boot monolith - Kotlin only - JPA / Hibernate - 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 - 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 @@ -52,76 +52,16 @@ Property access via PropertyUser Roles are per property Every API call must enforce property membership -CURRENT DOMAIN MODEL ALREADY CREATED - -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 - +IMMUTABLE RULES - Use Kotlin only - Follow existing package structure - No speculative features - No premature microservices - 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 -- Money logic must be explicit and auditable -- Canonical staff roles for now are ADMIN, MANAGER, STAFF, HOUSEKEEPING, FINANCE. Other roles may exist later but must not be depended on unless asked. -- 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 +- Canonical staff roles: ADMIN, MANAGER, STAFF, HOUSEKEEPING, FINANCE (other roles may exist but must not be depended on) +- Booking is a lifecycle container only. Booking does not own rooms or money. Occupancy via RoomStay. Billing via Charge/Payment ledgers. +- Realtime must emit derived events only (no raw entity subscriptions) - 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) @@ -129,119 +69,76 @@ CURRENT CODEBASE UNDERSTANDING (TrisolarisServer) Repository - Root: /home/androidlover5842/IdeaProjects/TrisolarisServer -- Language: Kotlin only (Spring Boot 4, JPA) -- Entry point: src/main/kotlin/com/android/trisolarisserver/TrisolarisServerApplication.kt -- Active controller layer is minimal (Rooms.kt is stubbed/commented) +- Entry: src/main/kotlin/com/android/trisolarisserver/TrisolarisServerApplication.kt +- Scheduling enabled (@EnableScheduling) -Gradle -- build.gradle.kts uses: - - Spring Boot 4.0.1 - - Kotlin 2.2.21 (kotlin("jvm"), kotlin("plugin.spring"), kotlin("plugin.jpa")) - - Java toolchain 19 - - JPA, WebMVC, Validation, Security, WebSocket, Flyway (dep), Postgres -- Flyway is disabled in application.properties +Security/Auth +- Firebase Admin auth for every request, Firebase UID required. +- Security filter verifies token and maps to MyPrincipal(userId, firebaseUid). +- Endpoints: /auth/verify and /auth/me. -Configuration -- src/main/resources/application.properties - - spring.jpa.hibernate.ddl-auto=update - - spring.jpa.open-in-view=false - - flyway.enabled=false -- application-dev.properties -> jdbc:postgresql://192.168.1.53:5432/trisolaris -- application-prod.properties -> jdbc:postgresql://localhost:5432/trisolaris -- DB password via env: DB_PASSWORD - -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 +Domain entities +- Organization: id, name, emailAliases +- Property: org, code, name, addressText, timezone, currency, active, emailAddresses, otaAliases +- AppUser: org, firebaseUid, phoneE164, name, disabled +- PropertyUser: roles per property +- Role enum includes ADMIN, MANAGER, STAFF, HOUSEKEEPING, FINANCE, GUIDE, SUPERVISOR, AGENT (but only canonical roles should be used) +- RoomType: code/name/occupancy + otaAliases +- Room: roomNumber, floor, hasNfc, active, maintenance, notes +- Booking: status, source/sourceBookingId, expected check-in/out, emailAuditPdfUrl - Guest - - id (uuid) - - org (Organization) - - phoneE164, name, nationality - - addressText - - createdAt, updatedAt - RoomStay - - id (uuid) - - property (Property) - - booking (Booking) - - room (Room) - - fromAt, toAt (null = active occupancy) - - createdBy (AppUser) - - createdAt +- GuestDocument: files for guest/booking with AI-extracted data +- InboundEmail: inbound mail audit (PDF + raw eml), extractedData, status -Notes on schema vs migration file -- There is a Flyway migration file at src/main/resources/db/migration/V1__core.sql, - but Flyway is disabled. The SQL file does NOT match current Kotlin entities in - multiple places (columns and tables differ). For now, JPA schema generation is - authoritative during development. +Repos +- Repos are under com.android.trisolarisserver.repo (note: not db.repo). +- Added repos for Booking, Guest, GuestDocument, InboundEmail. -Gaps relative to target design (do not implement unless asked) -- 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 +Key modules -Behavioral requirements to keep in mind when coding -- Every domain object must include property scope -- Room availability derived from RoomStay toAt == null -- Room changes are new RoomStay + closing old -- Charges and payments are append-only (never overwrite totals) -- Clients send intent; server derives facts +Rooms / inventory +- /properties/{propertyId}/rooms +- /properties/{propertyId}/rooms/board +- /properties/{propertyId}/rooms/availability +- /properties/{propertyId}/rooms/availability-range?from=YYYY-MM-DD&to=YYYY-MM-DD +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. diff --git a/src/main/kotlin/com/android/trisolarisserver/component/EmailStorage.kt b/src/main/kotlin/com/android/trisolarisserver/component/EmailStorage.kt index e5faf88..d22a494 100644 --- a/src/main/kotlin/com/android/trisolarisserver/component/EmailStorage.kt +++ b/src/main/kotlin/com/android/trisolarisserver/component/EmailStorage.kt @@ -101,6 +101,18 @@ class EmailStorage( 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) { try { Files.move(tmp, target, java.nio.file.StandardCopyOption.ATOMIC_MOVE, java.nio.file.StandardCopyOption.REPLACE_EXISTING) diff --git a/src/main/kotlin/com/android/trisolarisserver/component/RoomImageStorage.kt b/src/main/kotlin/com/android/trisolarisserver/component/RoomImageStorage.kt new file mode 100644 index 0000000..37cc965 --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/component/RoomImageStorage.kt @@ -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 +) diff --git a/src/main/kotlin/com/android/trisolarisserver/controller/InboundEmailManual.kt b/src/main/kotlin/com/android/trisolarisserver/controller/InboundEmailManual.kt new file mode 100644 index 0000000..13f76d8 --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/controller/InboundEmailManual.kt @@ -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 +) diff --git a/src/main/kotlin/com/android/trisolarisserver/controller/RoomImages.kt b/src/main/kotlin/com/android/trisolarisserver/controller/RoomImages.kt new file mode 100644 index 0000000..e856af2 --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/controller/RoomImages.kt @@ -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 { + 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 { + 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() + ) +} diff --git a/src/main/kotlin/com/android/trisolarisserver/controller/Rooms.kt b/src/main/kotlin/com/android/trisolarisserver/controller/Rooms.kt index 63587ae..a3c9ff7 100644 --- a/src/main/kotlin/com/android/trisolarisserver/controller/Rooms.kt +++ b/src/main/kotlin/com/android/trisolarisserver/controller/Rooms.kt @@ -1,6 +1,7 @@ package com.android.trisolarisserver.controller 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.RoomBoardResponse 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.RestController import org.springframework.web.server.ResponseStatusException +import java.time.LocalDate +import java.time.ZoneId import java.util.UUID @RestController @@ -106,6 +109,43 @@ class Rooms( }.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 { + 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 @ResponseStatus(HttpStatus.CREATED) fun createRoom( @@ -182,6 +222,14 @@ class Rooms( val privileged = setOf(Role.ADMIN, Role.MANAGER, Role.STAFF, Role.HOUSEKEEPING, Role.FINANCE) 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 { diff --git a/src/main/kotlin/com/android/trisolarisserver/controller/dto/RoomDtos.kt b/src/main/kotlin/com/android/trisolarisserver/controller/dto/RoomDtos.kt index 833c731..a30424d 100644 --- a/src/main/kotlin/com/android/trisolarisserver/controller/dto/RoomDtos.kt +++ b/src/main/kotlin/com/android/trisolarisserver/controller/dto/RoomDtos.kt @@ -25,6 +25,23 @@ data class RoomAvailabilityResponse( val freeRoomNumbers: List ) +data class RoomAvailabilityRangeResponse( + val roomTypeName: String, + val freeRoomNumbers: List, + 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 { FREE, OCCUPIED, diff --git a/src/main/kotlin/com/android/trisolarisserver/models/room/RoomImage.kt b/src/main/kotlin/com/android/trisolarisserver/models/room/RoomImage.kt new file mode 100644 index 0000000..ec0fe88 --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/models/room/RoomImage.kt @@ -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() +) diff --git a/src/main/kotlin/com/android/trisolarisserver/repo/RoomImageRepo.kt b/src/main/kotlin/com/android/trisolarisserver/repo/RoomImageRepo.kt new file mode 100644 index 0000000..60c7607 --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/repo/RoomImageRepo.kt @@ -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 { + fun findByRoomIdOrderByCreatedAtDesc(roomId: UUID): List + fun findByIdAndRoomIdAndPropertyId(id: UUID, roomId: UUID, propertyId: UUID): RoomImage? +} diff --git a/src/main/kotlin/com/android/trisolarisserver/repo/RoomStayRepo.kt b/src/main/kotlin/com/android/trisolarisserver/repo/RoomStayRepo.kt index d636762..84b48e8 100644 --- a/src/main/kotlin/com/android/trisolarisserver/repo/RoomStayRepo.kt +++ b/src/main/kotlin/com/android/trisolarisserver/repo/RoomStayRepo.kt @@ -14,4 +14,17 @@ interface RoomStayRepo : JpaRepository { and rs.toAt is null """) fun findOccupiedRoomIds(@Param("propertyId") propertyId: UUID): List + + @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 } diff --git a/src/main/kotlin/com/android/trisolarisserver/service/EmailIngestionService.kt b/src/main/kotlin/com/android/trisolarisserver/service/EmailIngestionService.kt index f87d962..0782db7 100644 --- a/src/main/kotlin/com/android/trisolarisserver/service/EmailIngestionService.kt +++ b/src/main/kotlin/com/android/trisolarisserver/service/EmailIngestionService.kt @@ -115,57 +115,7 @@ class EmailIngestionService( 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 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) + handleExtracted(property, inbound, body, "email:$messageId") } private fun extractBookingDetails(body: String): Map { @@ -241,6 +191,65 @@ class EmailIngestionService( 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? { val header = message.getHeader("Message-ID")?.firstOrNull() if (!header.isNullOrBlank()) return header diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 67e59d8..2fd9da6 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -11,6 +11,8 @@ storage.documents.tokenSecret=change-me storage.documents.tokenTtlSeconds=300 storage.emails.root=/home/androidlover5842/docs/emails 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.port=993 mail.imap.protocol=imaps