Compare commits

..

10 Commits

Author SHA1 Message Date
androidlover5842
de7e293097 build guest rating system 2026-01-26 15:09:20 +05:30
androidlover5842
31398d3822 fix compileation eror 2026-01-26 14:56:52 +05:30
androidlover5842
9835435aab update ai data 2026-01-24 23:57:01 +05:30
androidlover5842
094673b475 move card issuence server side 2026-01-24 23:53:03 +05:30
androidlover5842
ab7f02ddc6 add see update 2026-01-24 23:18:15 +05:30
androidlover5842
4a2834819f pre assign rooms before checkin 2026-01-24 23:07:45 +05:30
androidlover5842
57ca477c08 add booking flow 2026-01-24 22:58:54 +05:30
androidlover5842
72d9f5bb12 modes of transport 2026-01-24 22:39:45 +05:30
androidlover5842
6b6d84e40a more codes 2026-01-24 22:22:37 +05:30
androidlover5842
0d3472c60e filter mails by property contact alias 2026-01-24 21:57:06 +05:30
44 changed files with 2547 additions and 295 deletions

346
AGENTS.md
View File

@@ -1,127 +1,36 @@
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 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) - Flyway deps present but disabled (no migrations during dev)
- Single API domain api.hoteltrisolaris.in - Single API domain api.hoteltrisolaris.in
- Android app and future website consume the same APIs Server specs (current)
- CPU: i5-8400
- RAM: 48 GB
- GPU: RTX 3060 (used for llama.cpp)
This replaces a very old Firestore-based Android app. Old code exists only to understand behaviour. Do not reuse or mirror old models. Core principles
- Server is source of truth; clients send intent.
- Ledger-based design: never store totals; append rows only.
- Occupancy = RoomStay. Billing = Charge. Payments = Payment. Invoices are derived.
- Room availability by room number; toAt=null means occupied.
- Room change = close old RoomStay + open new one.
- Multi-property: every domain object scoped to property_id.
- Users belong to org; access granted per property.
CORE DESIGN PRINCIPLES Immutable rules
- Use Kotlin only; no microservices.
Server is the only source of truth - Flyway must remain disabled until schema stabilizes.
Clients never calculate state - Canonical staff roles: ADMIN, MANAGER, STAFF, HOUSEKEEPING, FINANCE.
Clients send intent, server derives facts - Booking does not own rooms or money.
- Realtime events must be derived, not raw DB changes.
Ledger-based design - Ask before touching auth or payment logic.
Never store totals
Never overwrite money
Append rows and derive views
Three independent concerns
Occupancy handled by RoomStay
Charges handled by Charge
Payments handled by Payment
Invoices are derived views, not stored state
Room availability is by room number
Availability is derived from RoomStay
toAt = null means occupied
Category counts are only summaries
Room changes must never break billing
Changing rooms means closing one RoomStay and opening another
Charges are time-bound and linked to booking, optionally to room_stay
Multi-property from day one
Every domain object is scoped to property_id
Users belong to organization
Access is granted per property
Auth and access
User exists once as AppUser
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
- 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
- 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 +38,120 @@ 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 - /auth/verify and /auth/me.
- 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
Configuration Domain entities
- src/main/resources/application.properties - Organization: name, emailAliases, allowedTransportModes.
- spring.jpa.hibernate.ddl-auto=update - Property: code, name, addressText, emailAddresses, otaAliases, allowedTransportModes.
- spring.jpa.open-in-view=false - AppUser, PropertyUser (roles per property).
- flyway.enabled=false - RoomType: code/name/occupancy + otaAliases.
- application-dev.properties -> jdbc:postgresql://192.168.1.53:5432/trisolaris - Room: roomNumber, floor, hasNfc, active, maintenance, notes.
- application-prod.properties -> jdbc:postgresql://localhost:5432/trisolaris - Booking: status, expected check-in/out, emailAuditPdfUrl, transportMode, transportVehicleNumber.
- DB password via env: DB_PASSWORD - Guest (org-scoped).
- RoomStay.
- RoomStayChange (idempotent room move).
- IssuedCard (cardId, cardIndex, issuedAt, expiresAt, issuedBy, revokedAt).
- PropertyCardCounter (per-property cardIndex counter).
- GuestDocument (files + AI-extracted json).
- GuestVehicle (org-scoped vehicle numbers).
- InboundEmail (audit PDF + raw EML, extracted json, status).
- RoomImage (original + thumbnail).
Current packages and code Key modules
com.android.trisolarisserver.component Auth
- PropertyAccess - /auth/verify
- requireMember(propertyId, userId) -> checks PropertyUserRepo - /auth/me
- requireAnyRole(propertyId, userId, roles) -> checks roles
com.android.trisolarisserver.db.repo Organizations / Properties / Users
- PropertyUserRepo - POST /orgs
- existsByIdPropertyIdAndIdUserId(...) - GET /orgs/{orgId}
- hasAnyRole(...) via JPQL joining property_user_role - POST /orgs/{orgId}/properties
- RoomRepo - GET /orgs/{orgId}/properties
- findFreeRooms(propertyId): active, not maintenance, no open RoomStay - PUT /properties/{propertyId}
- findOccupiedRooms(propertyId): rooms with active RoomStay - GET /orgs/{orgId}/users
- POST /orgs/{orgId}/users (removed; users created by app)
- GET /properties/{propertyId}/users
- PUT /properties/{propertyId}/users/{userId}/roles
- DELETE /properties/{propertyId}/users/{userId} (ADMIN only)
com.android.trisolarisserver.controller Rooms / inventory
- Rooms.kt: placeholder, no active endpoints yet - /properties/{propertyId}/rooms
- /properties/{propertyId}/rooms/board
- /properties/{propertyId}/rooms/board/stream (SSE)
- /properties/{propertyId}/rooms/availability
- /properties/{propertyId}/rooms/availability-range?from=YYYY-MM-DD&to=YYYY-MM-DD
Entity model (current Kotlin entities) Room types
- Organization - POST /properties/{propertyId}/room-types
- id (uuid), name, createdAt - GET /properties/{propertyId}/room-types
- Property - PUT /properties/{propertyId}/room-types/{roomTypeId}
- id (uuid) - DELETE /properties/{propertyId}/room-types/{roomTypeId}
- 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
- 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
Notes on schema vs migration file Properties / Orgs
- There is a Flyway migration file at src/main/resources/db/migration/V1__core.sql, - Property create/update accepts addressText, otaAliases, emailAddresses, allowedTransportModes.
but Flyway is disabled. The SQL file does NOT match current Kotlin entities in - Org create/get returns emailAliases + allowedTransportModes.
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) Booking flow
- No Charge entity yet - /properties/{propertyId}/bookings/{bookingId}/check-in (creates RoomStay rows)
- No Payment entity yet - /properties/{propertyId}/bookings/{bookingId}/check-out (closes RoomStay)
- No ledger/derived views - /properties/{propertyId}/bookings/{bookingId}/cancel
- No API controllers/services for bookings, rooms, payments - /properties/{propertyId}/bookings/{bookingId}/no-show
- No auth filter or principal model wired (PropertyAccess expects userId) - /properties/{propertyId}/bookings/{bookingId}/room-stays (pre-assign RoomStay with date range)
- No WebSocket/SSE endpoints yet - /properties/{propertyId}/room-stays/{roomStayId}/change-room (idempotent via RoomStayChange)
Behavioral requirements to keep in mind when coding Card issuing
- Every domain object must include property scope - /properties/{propertyId}/room-stays/{roomStayId}/cards/prepare -> returns cardIndex + sector0 payload
- Room availability derived from RoomStay toAt == null - /properties/{propertyId}/room-stays/{roomStayId}/cards -> store issued card
- Room changes are new RoomStay + closing old - /properties/{propertyId}/room-stays/{roomStayId}/cards (list)
- Charges and payments are append-only (never overwrite totals) - /properties/{propertyId}/room-stays/cards/{cardId}/revoke (ADMIN only)
- Clients send intent; server derives facts
Guest APIs
- /properties/{propertyId}/guests/search?phone=... or ?vehicleNumber=...
- /properties/{propertyId}/guests/{guestId}/vehicles (add vehicle)
Guest documents
- /properties/{propertyId}/guests/{guestId}/documents (upload/list)
- /properties/{propertyId}/guests/{guestId}/documents/{documentId}/file
- AI extraction with strict system prompt.
Room images
- /properties/{propertyId}/rooms/{roomId}/images (upload/list)
- /properties/{propertyId}/rooms/{roomId}/images/{imageId}/file
- Thumbnails generated (320px).
Transport modes
- /properties/{propertyId}/transport-modes -> returns enabled list (property > org > default all).
Inbound email ingestion
- IMAP poller (1 min) with enable flag.
- Saves audit PDF + raw .eml under /home/androidlover5842/docs/emails.
- Property match: To/CC email first; fallback to name/code/address/otaAliases.
- AI extracts booking fields; creates/cancels Booking.
- /properties/{propertyId}/inbound-emails/{emailId}/file (audit PDF)
- POST /properties/{propertyId}/inbound-emails/manual (PDF upload)
Realtime
- SSE room board events with heartbeat, on room create/update, check-in/out, and room change.
AI integration
- Base URL per profile: dev=https://ai.hoteltrisolaris.in/v1/chat/completions, prod=http://localhost:8089/v1/chat/completions
- LlamaClient uses strict system prompt (no guessing).
- Read timeout 5 minutes.
Config
- storage.documents.root=/home/androidlover5842/docs
- storage.emails.root=/home/androidlover5842/docs/emails
- storage.rooms.root=/home/androidlover5842/docs/rooms
- publicBaseUrl entries for docs/emails/rooms
- mail.imap.enabled=false by default
Notes / constraints
- 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.

View File

@@ -25,13 +25,14 @@ dependencies {
implementation("org.springframework.boot:spring-boot-starter-flyway") implementation("org.springframework.boot:spring-boot-starter-flyway")
implementation("org.springframework.boot:spring-boot-starter-validation") implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.springframework.boot:spring-boot-starter-webmvc") implementation("org.springframework.boot:spring-boot-starter-webmvc")
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-websocket") implementation("org.springframework.boot:spring-boot-starter-websocket")
implementation("org.flywaydb:flyway-database-postgresql") implementation("org.flywaydb:flyway-database-postgresql")
implementation("org.jetbrains.kotlin:kotlin-reflect") implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("tools.jackson.module:jackson-module-kotlin") implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.springframework.boot:spring-boot-starter-security") implementation("org.springframework.boot:spring-boot-starter-security")
implementation("com.google.firebase:firebase-admin:9.7.0") implementation("com.google.firebase:firebase-admin:9.7.0")
implementation("com.sun.mail:jakarta.mail:2.0.1") implementation("com.sun.mail:jakarta.mail:2.0.2")
implementation("org.apache.pdfbox:pdfbox:2.0.30") implementation("org.apache.pdfbox:pdfbox:2.0.30")
runtimeOnly("org.postgresql:postgresql") runtimeOnly("org.postgresql:postgresql")
testImplementation("org.springframework.boot:spring-boot-starter-data-jpa-test") testImplementation("org.springframework.boot:spring-boot-starter-data-jpa-test")

View File

@@ -28,14 +28,16 @@ class EmailStorage(
val safeName = (messageId ?: UUID.randomUUID().toString()).replace(Regex("[^A-Za-z0-9._-]"), "_") val safeName = (messageId ?: UUID.randomUUID().toString()).replace(Regex("[^A-Za-z0-9._-]"), "_")
val fileName = "${safeName}_${OffsetDateTime.now().toEpochSecond()}.pdf" val fileName = "${safeName}_${OffsetDateTime.now().toEpochSecond()}.pdf"
val path = dir.resolve(fileName).normalize() val path = dir.resolve(fileName).normalize()
val tmp = dir.resolve("${fileName}.tmp").normalize()
val document = PDDocument() val document = PDDocument()
val page = PDPage(PDRectangle.LETTER) val page = PDPage(PDRectangle.LETTER)
document.addPage(page) document.addPage(page)
val header = "Subject: ${subject ?: ""}\n\n" val header = "Subject: ${subject ?: ""}\n\n"
writeText(document, page, header + body) writeText(document, page, header + body)
document.save(path.toFile()) document.save(tmp.toFile())
document.close() document.close()
atomicMove(tmp, path)
return path.toString() return path.toString()
} }
@@ -82,4 +84,40 @@ class EmailStorage(
content.endText() content.endText()
content.close() content.close()
} }
fun storeEml(propertyId: UUID?, messageId: String?, rawBytes: ByteArray): String {
val dir = if (propertyId != null) {
Paths.get(rootPath, propertyId.toString(), "raw")
} else {
Paths.get(rootPath, "unassigned", "raw")
}
Files.createDirectories(dir)
val safeName = (messageId ?: UUID.randomUUID().toString()).replace(Regex("[^A-Za-z0-9._-]"), "_")
val fileName = "${safeName}_${OffsetDateTime.now().toEpochSecond()}.eml"
val path = dir.resolve(fileName).normalize()
val tmp = dir.resolve("${fileName}.tmp").normalize()
Files.write(tmp, rawBytes)
atomicMove(tmp, path)
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)
} catch (_: Exception) {
Files.move(tmp, target, java.nio.file.StandardCopyOption.REPLACE_EXISTING)
}
}
} }

View File

@@ -0,0 +1,86 @@
package com.android.trisolarisserver.component
import com.android.trisolarisserver.controller.dto.RoomBoardResponse
import com.android.trisolarisserver.controller.dto.RoomBoardStatus
import com.android.trisolarisserver.repo.RoomRepo
import com.android.trisolarisserver.repo.RoomStayRepo
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Component
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter
import java.io.IOException
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList
@Component
class RoomBoardEvents(
private val roomRepo: RoomRepo,
private val roomStayRepo: RoomStayRepo
) {
private val emitters: MutableMap<UUID, CopyOnWriteArrayList<SseEmitter>> = ConcurrentHashMap()
fun subscribe(propertyId: UUID): SseEmitter {
val emitter = SseEmitter(0L)
emitters.computeIfAbsent(propertyId) { CopyOnWriteArrayList() }.add(emitter)
emitter.onCompletion { emitters[propertyId]?.remove(emitter) }
emitter.onTimeout { emitters[propertyId]?.remove(emitter) }
emitter.onError { emitters[propertyId]?.remove(emitter) }
try {
emitter.send(SseEmitter.event().name("room-board").data(buildSnapshot(propertyId)))
} catch (_: IOException) {
emitters[propertyId]?.remove(emitter)
}
return emitter
}
fun emit(propertyId: UUID) {
val data = buildSnapshot(propertyId)
val list = emitters[propertyId] ?: return
val dead = mutableListOf<SseEmitter>()
for (emitter in list) {
try {
emitter.send(SseEmitter.event().name("room-board").data(data))
} catch (_: IOException) {
dead.add(emitter)
}
}
if (dead.isNotEmpty()) {
list.removeAll(dead.toSet())
}
}
@Scheduled(fixedDelayString = "25000")
fun heartbeat() {
emitters.forEach { (_, list) ->
val dead = mutableListOf<SseEmitter>()
for (emitter in list) {
try {
emitter.send(SseEmitter.event().name("ping").data("ok"))
} catch (_: IOException) {
dead.add(emitter)
}
}
if (dead.isNotEmpty()) {
list.removeAll(dead.toSet())
}
}
}
private fun buildSnapshot(propertyId: UUID): List<RoomBoardResponse> {
val rooms = roomRepo.findByPropertyIdOrderByRoomNumber(propertyId)
val occupiedRoomIds = roomStayRepo.findOccupiedRoomIds(propertyId).toHashSet()
return rooms.map { room ->
val status = when {
room.maintenance -> RoomBoardStatus.MAINTENANCE
!room.active -> RoomBoardStatus.INACTIVE
occupiedRoomIds.contains(room.id) -> RoomBoardStatus.OCCUPIED
else -> RoomBoardStatus.FREE
}
RoomBoardResponse(
roomNumber = room.roomNumber,
roomTypeName = room.roomType.name,
status = status
)
}
}
}

View File

@@ -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
)

View File

@@ -1,18 +1,18 @@
package com.android.trisolarisserver.config package com.android.trisolarisserver.config
import org.springframework.boot.web.client.RestTemplateBuilder
import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Configuration
import org.springframework.http.client.SimpleClientHttpRequestFactory
import org.springframework.web.client.RestTemplate import org.springframework.web.client.RestTemplate
import java.time.Duration
@Configuration @Configuration
class HttpConfig { class HttpConfig {
@Bean @Bean
fun restTemplate(builder: RestTemplateBuilder): RestTemplate { fun restTemplate(): RestTemplate {
return builder val factory = SimpleClientHttpRequestFactory().apply {
.setConnectTimeout(Duration.ofSeconds(10)) setConnectTimeout(10_000)
.setReadTimeout(Duration.ofMinutes(5)) setReadTimeout(5 * 60 * 1_000)
.build() }
return RestTemplate(factory)
} }
} }

View File

@@ -0,0 +1,277 @@
package com.android.trisolarisserver.controller
import com.android.trisolarisserver.component.PropertyAccess
import com.android.trisolarisserver.component.RoomBoardEvents
import com.android.trisolarisserver.controller.dto.BookingCancelRequest
import com.android.trisolarisserver.controller.dto.BookingCheckInRequest
import com.android.trisolarisserver.controller.dto.BookingCheckOutRequest
import com.android.trisolarisserver.controller.dto.BookingNoShowRequest
import com.android.trisolarisserver.controller.dto.RoomStayPreAssignRequest
import com.android.trisolarisserver.db.repo.BookingRepo
import com.android.trisolarisserver.models.booking.BookingStatus
import com.android.trisolarisserver.models.booking.TransportMode
import com.android.trisolarisserver.models.room.RoomStay
import com.android.trisolarisserver.models.property.Role
import com.android.trisolarisserver.repo.AppUserRepo
import com.android.trisolarisserver.repo.RoomRepo
import com.android.trisolarisserver.repo.RoomStayRepo
import com.android.trisolarisserver.security.MyPrincipal
import org.springframework.http.HttpStatus
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
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.OffsetDateTime
import java.util.UUID
@RestController
@RequestMapping("/properties/{propertyId}/bookings")
class BookingFlow(
private val propertyAccess: PropertyAccess,
private val bookingRepo: BookingRepo,
private val roomRepo: RoomRepo,
private val roomStayRepo: RoomStayRepo,
private val appUserRepo: AppUserRepo,
private val roomBoardEvents: RoomBoardEvents
) {
@PostMapping("/{bookingId}/check-in")
@ResponseStatus(HttpStatus.CREATED)
@Transactional
fun checkIn(
@PathVariable propertyId: UUID,
@PathVariable bookingId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: BookingCheckInRequest
) {
val actor = requireActor(propertyId, principal)
val roomIds = request.roomIds.distinct()
if (roomIds.isEmpty()) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "roomIds required")
}
val booking = requireBooking(propertyId, bookingId)
if (booking.status != BookingStatus.OPEN) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Booking not open")
}
val now = OffsetDateTime.now()
val checkInAt = parseOffset(request.checkInAt) ?: now
val rooms = roomIds.map { roomId ->
val room = roomRepo.findByIdAndPropertyId(roomId, propertyId)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room not found")
if (!room.active || room.maintenance) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Room not available")
}
room
}
val occupied = roomStayRepo.findActiveRoomIds(propertyId, rooms.mapNotNull { it.id })
if (occupied.isNotEmpty()) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Room already occupied")
}
rooms.forEach { room ->
val stay = RoomStay(
property = booking.property,
booking = booking,
room = room,
fromAt = checkInAt,
toAt = null,
createdBy = actor
)
roomStayRepo.save(stay)
}
booking.status = BookingStatus.CHECKED_IN
booking.checkinAt = checkInAt
booking.transportMode = request.transportMode?.let {
val mode = parseTransportMode(it)
if (!isTransportModeAllowed(booking.property, mode)) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Transport mode disabled")
}
mode
}
booking.transportVehicleNumber = request.transportVehicleNumber
if (request.notes != null) booking.notes = request.notes
booking.updatedAt = now
bookingRepo.save(booking)
roomBoardEvents.emit(propertyId)
}
@PostMapping("/{bookingId}/check-out")
@ResponseStatus(HttpStatus.NO_CONTENT)
@Transactional
fun checkOut(
@PathVariable propertyId: UUID,
@PathVariable bookingId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: BookingCheckOutRequest
) {
requireActor(propertyId, principal)
val booking = requireBooking(propertyId, bookingId)
if (booking.status != BookingStatus.CHECKED_IN) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Booking not checked in")
}
val now = OffsetDateTime.now()
val checkOutAt = parseOffset(request.checkOutAt) ?: now
val stays = roomStayRepo.findActiveByBookingId(bookingId)
stays.forEach { it.toAt = checkOutAt }
roomStayRepo.saveAll(stays)
booking.status = BookingStatus.CHECKED_OUT
booking.checkoutAt = checkOutAt
if (request.notes != null) booking.notes = request.notes
booking.updatedAt = now
bookingRepo.save(booking)
roomBoardEvents.emit(propertyId)
}
@PostMapping("/{bookingId}/cancel")
@ResponseStatus(HttpStatus.NO_CONTENT)
@Transactional
fun cancel(
@PathVariable propertyId: UUID,
@PathVariable bookingId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: BookingCancelRequest
) {
requireActor(propertyId, principal)
val booking = requireBooking(propertyId, bookingId)
if (booking.status == BookingStatus.CHECKED_IN) {
val active = roomStayRepo.findActiveByBookingId(bookingId)
if (active.isNotEmpty()) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Cannot cancel checked-in booking")
}
}
booking.status = BookingStatus.CANCELLED
if (request.reason != null) booking.notes = request.reason
booking.updatedAt = OffsetDateTime.now()
bookingRepo.save(booking)
}
@PostMapping("/{bookingId}/no-show")
@ResponseStatus(HttpStatus.NO_CONTENT)
@Transactional
fun noShow(
@PathVariable propertyId: UUID,
@PathVariable bookingId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: BookingNoShowRequest
) {
requireActor(propertyId, principal)
val booking = requireBooking(propertyId, bookingId)
if (booking.status != BookingStatus.OPEN) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Booking not open")
}
booking.status = BookingStatus.NO_SHOW
if (request.reason != null) booking.notes = request.reason
booking.updatedAt = OffsetDateTime.now()
bookingRepo.save(booking)
}
@PostMapping("/{bookingId}/room-stays")
@ResponseStatus(HttpStatus.CREATED)
@Transactional
fun preAssignRoom(
@PathVariable propertyId: UUID,
@PathVariable bookingId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: RoomStayPreAssignRequest
) {
val actor = requireActor(propertyId, principal)
val booking = requireBooking(propertyId, bookingId)
if (booking.status == BookingStatus.CANCELLED || booking.status == BookingStatus.NO_SHOW || booking.status == BookingStatus.CHECKED_OUT) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Booking closed")
}
val room = roomRepo.findByIdAndPropertyId(request.roomId, propertyId)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room not found")
if (!room.active || room.maintenance) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Room not available")
}
val fromAt = parseOffset(request.fromAt)
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "fromAt required")
val toAt = parseOffset(request.toAt)
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "toAt required")
if (!toAt.isAfter(fromAt)) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid date range")
}
if (roomStayRepo.existsOverlap(propertyId, request.roomId, fromAt, toAt)) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Room already reserved/occupied for range")
}
val stay = RoomStay(
property = booking.property,
booking = booking,
room = room,
fromAt = fromAt,
toAt = toAt,
createdBy = actor
)
roomStayRepo.save(stay)
}
private fun requireBooking(propertyId: UUID, bookingId: UUID): com.android.trisolarisserver.models.booking.Booking {
val booking = bookingRepo.findById(bookingId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found")
}
if (booking.property.id != propertyId) {
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found for property")
}
return booking
}
private fun requireActor(propertyId: UUID, principal: MyPrincipal?): com.android.trisolarisserver.models.property.AppUser {
requirePrincipal(principal)
propertyAccess.requireMember(propertyId, principal!!.userId)
propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN, Role.MANAGER, Role.STAFF)
return appUserRepo.findById(principal.userId).orElseThrow {
ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found")
}
}
private fun parseOffset(value: String?): OffsetDateTime? {
if (value.isNullOrBlank()) return null
return try {
OffsetDateTime.parse(value.trim())
} catch (_: Exception) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid timestamp")
}
}
private fun parseTransportMode(value: String): TransportMode {
return try {
TransportMode.valueOf(value)
} catch (_: IllegalArgumentException) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown transport mode")
}
}
private fun isTransportModeAllowed(
property: com.android.trisolarisserver.models.property.Property,
mode: TransportMode
): Boolean {
val allowed = when {
property.allowedTransportModes.isNotEmpty() -> property.allowedTransportModes
property.org.allowedTransportModes.isNotEmpty() -> property.org.allowedTransportModes
else -> TransportMode.entries.toSet()
}
return allowed.contains(mode)
}
private fun requirePrincipal(principal: MyPrincipal?) {
if (principal == null) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
}
}
}

View File

@@ -0,0 +1,136 @@
package com.android.trisolarisserver.controller
import com.android.trisolarisserver.component.PropertyAccess
import com.android.trisolarisserver.controller.dto.GuestRatingCreateRequest
import com.android.trisolarisserver.controller.dto.GuestRatingResponse
import com.android.trisolarisserver.db.repo.BookingRepo
import com.android.trisolarisserver.db.repo.GuestRatingRepo
import com.android.trisolarisserver.db.repo.GuestRepo
import com.android.trisolarisserver.repo.AppUserRepo
import com.android.trisolarisserver.repo.PropertyRepo
import com.android.trisolarisserver.models.booking.GuestRating
import com.android.trisolarisserver.models.booking.GuestRatingScore
import com.android.trisolarisserver.security.MyPrincipal
import org.springframework.http.HttpStatus
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.RequestBody
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.util.UUID
@RestController
@RequestMapping("/properties/{propertyId}/guests/{guestId}/ratings")
class GuestRatings(
private val propertyAccess: PropertyAccess,
private val propertyRepo: PropertyRepo,
private val guestRepo: GuestRepo,
private val bookingRepo: BookingRepo,
private val guestRatingRepo: GuestRatingRepo,
private val appUserRepo: AppUserRepo
) {
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
fun create(
@PathVariable propertyId: UUID,
@PathVariable guestId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: GuestRatingCreateRequest
): GuestRatingResponse {
requirePrincipal(principal)
propertyAccess.requireMember(propertyId, principal!!.userId)
val property = propertyRepo.findById(propertyId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
}
val guest = guestRepo.findById(guestId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Guest not found")
}
if (guest.org.id != property.org.id) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Guest not in property org")
}
val booking = bookingRepo.findById(request.bookingId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found")
}
if (booking.property.id != property.id) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Booking not in property")
}
if (booking.primaryGuest?.id != guest.id) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Booking not linked to guest")
}
if (guestRatingRepo.existsByGuestIdAndBookingId(guest.id!!, booking.id!!)) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Rating already exists for booking")
}
val score = parseScore(request.score)
val rating = GuestRating(
org = property.org,
property = property,
guest = guest,
booking = booking,
score = score,
notes = request.notes?.trim(),
createdBy = appUserRepo.findById(principal.userId).orElse(null)
)
guestRatingRepo.save(rating)
return rating.toResponse()
}
@GetMapping
fun list(
@PathVariable propertyId: UUID,
@PathVariable guestId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
): List<GuestRatingResponse> {
requirePrincipal(principal)
propertyAccess.requireMember(propertyId, principal!!.userId)
val property = propertyRepo.findById(propertyId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
}
val guest = guestRepo.findById(guestId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Guest not found")
}
if (guest.org.id != property.org.id) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Guest not in property org")
}
return guestRatingRepo.findByGuestIdOrderByCreatedAtDesc(guestId).map { it.toResponse() }
}
private fun parseScore(raw: String): GuestRatingScore {
val normalized = raw.trim().uppercase()
return when (normalized) {
"1", "GOOD" -> GuestRatingScore.GOOD
"2", "OK" -> GuestRatingScore.OK
"3", "TROUBLE", "TROUBLEMAKER", "TROUBLE-MAKER" -> GuestRatingScore.TROUBLE
else -> throw ResponseStatusException(HttpStatus.BAD_REQUEST, "score must be GOOD/OK/TROUBLE or 1/2/3")
}
}
private fun GuestRating.toResponse(): GuestRatingResponse {
return GuestRatingResponse(
id = id!!,
orgId = org.id!!,
propertyId = property.id!!,
guestId = guest.id!!,
bookingId = booking.id!!,
score = score.name,
notes = notes,
createdAt = createdAt.toString(),
createdByUserId = createdBy?.id
)
}
private fun requirePrincipal(principal: MyPrincipal?) {
if (principal == null) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
}
}
}

View File

@@ -0,0 +1,128 @@
package com.android.trisolarisserver.controller
import com.android.trisolarisserver.component.PropertyAccess
import com.android.trisolarisserver.controller.dto.GuestResponse
import com.android.trisolarisserver.controller.dto.GuestVehicleRequest
import com.android.trisolarisserver.models.booking.Guest
import com.android.trisolarisserver.models.booking.GuestVehicle
import com.android.trisolarisserver.db.repo.GuestRepo
import com.android.trisolarisserver.db.repo.GuestRatingRepo
import com.android.trisolarisserver.repo.GuestVehicleRepo
import com.android.trisolarisserver.repo.PropertyRepo
import com.android.trisolarisserver.security.MyPrincipal
import org.springframework.http.HttpStatus
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.*
import org.springframework.web.server.ResponseStatusException
import java.util.UUID
@RestController
@RequestMapping("/properties/{propertyId}/guests")
class Guests(
private val propertyAccess: PropertyAccess,
private val propertyRepo: PropertyRepo,
private val guestRepo: GuestRepo,
private val guestVehicleRepo: GuestVehicleRepo,
private val guestRatingRepo: GuestRatingRepo
) {
@GetMapping("/search")
fun search(
@PathVariable propertyId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestParam(required = false) phone: String?,
@RequestParam(required = false) vehicleNumber: String?
): List<GuestResponse> {
requirePrincipal(principal)
propertyAccess.requireMember(propertyId, principal!!.userId)
if (phone.isNullOrBlank() && vehicleNumber.isNullOrBlank()) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "phone or vehicleNumber required")
}
val property = propertyRepo.findById(propertyId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
}
val orgId = property.org.id ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Org missing")
val guests = mutableSetOf<Guest>()
if (!phone.isNullOrBlank()) {
val guest = guestRepo.findByOrgIdAndPhoneE164(orgId, phone)
if (guest != null) guests.add(guest)
}
if (!vehicleNumber.isNullOrBlank()) {
val vehicle = guestVehicleRepo.findByOrgIdAndVehicleNumberIgnoreCase(orgId, vehicleNumber)
if (vehicle != null) guests.add(vehicle.guest)
}
return guests.toResponse(guestVehicleRepo, guestRatingRepo)
}
@PostMapping("/{guestId}/vehicles")
@ResponseStatus(HttpStatus.CREATED)
fun addVehicle(
@PathVariable propertyId: UUID,
@PathVariable guestId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: GuestVehicleRequest
): GuestResponse {
requirePrincipal(principal)
propertyAccess.requireMember(propertyId, principal!!.userId)
val property = propertyRepo.findById(propertyId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
}
val guest = guestRepo.findById(guestId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Guest not found")
}
if (guest.org.id != property.org.id) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Guest not in property org")
}
if (guestVehicleRepo.existsByOrgIdAndVehicleNumberIgnoreCase(property.org.id!!, request.vehicleNumber)) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Vehicle number already exists")
}
val vehicle = GuestVehicle(
org = property.org,
guest = guest,
vehicleNumber = request.vehicleNumber.trim()
)
guestVehicleRepo.save(vehicle)
return setOf(guest).toResponse(guestVehicleRepo, guestRatingRepo).first()
}
private fun requirePrincipal(principal: MyPrincipal?) {
if (principal == null) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
}
}
}
private fun Set<Guest>.toResponse(
guestVehicleRepo: GuestVehicleRepo,
guestRatingRepo: GuestRatingRepo
): List<GuestResponse> {
val ids = this.mapNotNull { it.id }
val vehicles = if (ids.isEmpty()) emptyList() else guestVehicleRepo.findByGuestIdIn(ids)
val vehiclesByGuest = vehicles.groupBy { it.guest.id }
val averages = if (ids.isEmpty()) {
emptyMap()
} else {
guestRatingRepo.findAverageScoreByGuestIds(ids).associate { row ->
val guestId = row[0] as UUID
val avg = row[1] as Double
guestId to avg
}
}
return this.map { guest ->
GuestResponse(
id = guest.id!!,
orgId = guest.org.id!!,
name = guest.name,
phoneE164 = guest.phoneE164,
nationality = guest.nationality,
addressText = guest.addressText,
vehicleNumbers = vehiclesByGuest[guest.id]?.map { it.vehicleNumber }?.toSet() ?: emptySet(),
averageScore = averages[guest.id]
)
}
}

View File

@@ -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
)

View File

@@ -0,0 +1,59 @@
package com.android.trisolarisserver.controller
import com.android.trisolarisserver.component.PropertyAccess
import com.android.trisolarisserver.db.repo.InboundEmailRepo
import com.android.trisolarisserver.models.property.Role
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.RequestMapping
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.server.ResponseStatusException
import java.nio.file.Files
import java.nio.file.Paths
import java.util.UUID
@RestController
@RequestMapping("/properties/{propertyId}/inbound-emails")
class InboundEmails(
private val propertyAccess: PropertyAccess,
private val inboundEmailRepo: InboundEmailRepo
) {
@GetMapping("/{emailId}/file")
fun downloadEmailPdf(
@PathVariable propertyId: UUID,
@PathVariable emailId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
): ResponseEntity<FileSystemResource> {
requirePrincipal(principal)
propertyAccess.requireMember(propertyId, principal!!.userId)
propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN, Role.MANAGER)
val email = inboundEmailRepo.findByIdAndPropertyId(emailId, propertyId)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Email not found")
val path = email.rawPdfPath ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Email PDF missing")
val file = Paths.get(path)
if (!Files.exists(file)) {
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Email PDF missing")
}
val resource = FileSystemResource(file)
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_PDF)
.header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"email-${emailId}.pdf\"")
.contentLength(resource.contentLength())
.body(resource)
}
private fun requirePrincipal(principal: MyPrincipal?) {
if (principal == null) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
}
}
}

View File

@@ -0,0 +1,298 @@
package com.android.trisolarisserver.controller
import com.android.trisolarisserver.component.PropertyAccess
import com.android.trisolarisserver.controller.dto.CardPrepareRequest
import com.android.trisolarisserver.controller.dto.CardPrepareResponse
import com.android.trisolarisserver.controller.dto.IssueCardRequest
import com.android.trisolarisserver.controller.dto.IssuedCardResponse
import com.android.trisolarisserver.models.property.Role
import com.android.trisolarisserver.models.room.IssuedCard
import com.android.trisolarisserver.repo.AppUserRepo
import com.android.trisolarisserver.repo.IssuedCardRepo
import com.android.trisolarisserver.repo.PropertyCardCounterRepo
import com.android.trisolarisserver.repo.PropertyRepo
import com.android.trisolarisserver.repo.RoomStayRepo
import com.android.trisolarisserver.security.MyPrincipal
import org.springframework.http.HttpStatus
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.transaction.annotation.Transactional
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.RequestBody
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.OffsetDateTime
import java.util.UUID
@RestController
@RequestMapping("/properties/{propertyId}/room-stays")
class IssuedCards(
private val propertyAccess: PropertyAccess,
private val roomStayRepo: RoomStayRepo,
private val issuedCardRepo: IssuedCardRepo,
private val appUserRepo: AppUserRepo,
private val counterRepo: PropertyCardCounterRepo,
private val propertyRepo: PropertyRepo
) {
@PostMapping("/{roomStayId}/cards/prepare")
@ResponseStatus(HttpStatus.CREATED)
@Transactional
fun prepare(
@PathVariable propertyId: UUID,
@PathVariable roomStayId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: CardPrepareRequest
): CardPrepareResponse {
requireIssueActor(propertyId, principal)
val stay = roomStayRepo.findById(roomStayId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Room stay not found")
}
if (stay.property.id != propertyId) {
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room stay not found for property")
}
if (stay.toAt != null) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Room stay closed")
}
val issuedAt = OffsetDateTime.now()
val expiresAt = request.expiresAt?.let { parseOffset(it) } ?: stay.toAt
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "expiresAt required")
if (!expiresAt.isAfter(issuedAt)) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "expiresAt must be after issuedAt")
}
val cardIndex = nextCardIndex(stay.property.id!!)
val payload = buildSector0Payload(stay.room.roomNumber, cardIndex, issuedAt, expiresAt)
return CardPrepareResponse(
cardIndex = cardIndex,
key = payload.key,
timeData = payload.timeData,
issuedAt = issuedAt.toString(),
expiresAt = expiresAt.toString()
)
}
@PostMapping("/{roomStayId}/cards")
@ResponseStatus(HttpStatus.CREATED)
@Transactional
fun issue(
@PathVariable propertyId: UUID,
@PathVariable roomStayId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: IssueCardRequest
): IssuedCardResponse {
val actor = requireIssueActor(propertyId, principal)
if (request.cardId.isBlank()) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "cardId required")
}
if (request.cardIndex <= 0) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "cardIndex required")
}
val stay = roomStayRepo.findById(roomStayId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Room stay not found")
}
if (stay.property.id != propertyId) {
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room stay not found for property")
}
if (stay.toAt != null) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Room stay closed")
}
val issuedAt = parseOffset(request.issuedAt) ?: OffsetDateTime.now()
val expiresAt = parseOffset(request.expiresAt)
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "expiresAt required")
if (!expiresAt.isAfter(issuedAt)) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "expiresAt must be after issuedAt")
}
val now = OffsetDateTime.now()
if (issuedCardRepo.existsActiveForRoomStay(roomStayId, now)) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Active card already exists for room stay")
}
if (issuedCardRepo.existsActiveForRoom(propertyId, stay.room.id!!, now)) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Active card already exists for room")
}
val card = IssuedCard(
property = stay.property,
room = stay.room,
roomStay = stay,
cardId = request.cardId.trim(),
cardIndex = request.cardIndex,
issuedAt = issuedAt,
expiresAt = expiresAt,
issuedBy = actor
)
return issuedCardRepo.save(card).toResponse()
}
@GetMapping("/{roomStayId}/cards")
fun list(
@PathVariable propertyId: UUID,
@PathVariable roomStayId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
): List<IssuedCardResponse> {
requireMember(propertyId, principal)
val stay = roomStayRepo.findById(roomStayId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Room stay not found")
}
if (stay.property.id != propertyId) {
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room stay not found for property")
}
return issuedCardRepo.findByRoomStayIdOrderByIssuedAtDesc(roomStayId)
.map { it.toResponse() }
}
@PostMapping("/cards/{cardId}/revoke")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun revoke(
@PathVariable propertyId: UUID,
@PathVariable cardId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
) {
requireRevokeActor(propertyId, principal)
val card = issuedCardRepo.findByIdAndPropertyId(cardId, propertyId)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Card not found")
if (card.revokedAt == null) {
card.revokedAt = OffsetDateTime.now()
issuedCardRepo.save(card)
}
}
private fun parseOffset(value: String?): OffsetDateTime? {
if (value.isNullOrBlank()) return null
return try {
OffsetDateTime.parse(value.trim())
} catch (_: Exception) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid timestamp")
}
}
private fun requireMember(propertyId: UUID, principal: MyPrincipal?) {
if (principal == null) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
}
propertyAccess.requireMember(propertyId, principal.userId)
}
private fun requireIssueActor(propertyId: UUID, principal: MyPrincipal?): com.android.trisolarisserver.models.property.AppUser {
if (principal == null) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
}
propertyAccess.requireMember(propertyId, principal.userId)
propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN, Role.MANAGER)
return appUserRepo.findById(principal.userId).orElseThrow {
ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found")
}
}
private fun requireRevokeActor(propertyId: UUID, principal: MyPrincipal?) {
if (principal == null) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
}
propertyAccess.requireMember(propertyId, principal.userId)
propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN)
}
private fun nextCardIndex(propertyId: UUID): Int {
var counter = counterRepo.findByPropertyIdForUpdate(propertyId)
if (counter == null) {
val property = propertyRepo.findById(propertyId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
}
counterRepo.save(com.android.trisolarisserver.models.room.PropertyCardCounter(
property = property,
nextIndex = 1
))
counter = counterRepo.findByPropertyIdForUpdate(propertyId)
}
val current = counter!!.nextIndex
counter.nextIndex = current + 1
counter.updatedAt = OffsetDateTime.now()
counterRepo.save(counter)
return current
}
private fun buildSector0Payload(
roomNumber: Int,
cardIndex: Int,
issuedAt: OffsetDateTime,
expiresAt: OffsetDateTime
): Sector0Payload {
val key = buildSector0Block2(roomNumber, cardIndex)
val newData = "0000000000" + formatDateComponents(issuedAt) + formatDateComponents(expiresAt)
val checkSum = calculateChecksum(key + newData)
val finalData = newData + checkSum
return Sector0Payload(key, finalData)
}
private fun buildSector0Block2(roomNumber: Int, cardID: Int): String {
val guestID = cardID + 1
val key = "${cardID}2F${guestID}"
val finalRoom = if (roomNumber < 10) "0$roomNumber" else roomNumber.toString()
return "472F${key}00010000${finalRoom}0000"
}
private fun formatDateComponents(time: OffsetDateTime): String {
val minute = time.minute.toString().padStart(2, '0')
val hour = time.hour.toString().padStart(2, '0')
val day = time.dayOfMonth.toString().padStart(2, '0')
val month = time.monthValue.toString().padStart(2, '0')
val year = time.year.toString().takeLast(2)
return "${minute}${hour}${day}${month}${year}"
}
private fun calculateChecksum(dataHex: String): String {
val data = hexStringToByteArray(dataHex)
var checksum = 0
for (byte in data) {
checksum = calculateByteChecksum(byte, checksum)
}
return String.format("%02X", checksum)
}
private fun calculateByteChecksum(byte: Byte, currentChecksum: Int): Int {
var checksum = currentChecksum
var b = byte.toInt()
for (i in 0 until 8) {
checksum = if ((checksum xor b) and 1 != 0) {
(checksum xor 0x18) shr 1 or 0x80
} else {
checksum shr 1
}
b = b shr 1
}
return checksum
}
private fun hexStringToByteArray(hexString: String): ByteArray {
val len = hexString.length
val data = ByteArray(len / 2)
for (i in 0 until len step 2) {
data[i / 2] = ((Character.digit(hexString[i], 16) shl 4)
+ Character.digit(hexString[i + 1], 16)).toByte()
}
return data
}
}
private data class Sector0Payload(
val key: String,
val timeData: String
)
private fun IssuedCard.toResponse(): IssuedCardResponse {
return IssuedCardResponse(
id = id!!,
propertyId = property.id!!,
roomId = room.id!!,
roomStayId = roomStay.id!!,
cardId = cardId,
cardIndex = cardIndex,
issuedAt = issuedAt.toString(),
expiresAt = expiresAt.toString(),
issuedByUserId = issuedBy?.id,
revokedAt = revokedAt?.toString()
)
}

View File

@@ -5,6 +5,7 @@ import com.android.trisolarisserver.controller.dto.OrgResponse
import com.android.trisolarisserver.repo.AppUserRepo import com.android.trisolarisserver.repo.AppUserRepo
import com.android.trisolarisserver.repo.OrganizationRepo import com.android.trisolarisserver.repo.OrganizationRepo
import com.android.trisolarisserver.repo.PropertyUserRepo import com.android.trisolarisserver.repo.PropertyUserRepo
import com.android.trisolarisserver.models.booking.TransportMode
import com.android.trisolarisserver.models.property.Organization import com.android.trisolarisserver.models.property.Organization
import com.android.trisolarisserver.models.property.Role import com.android.trisolarisserver.models.property.Role
import com.android.trisolarisserver.security.MyPrincipal import com.android.trisolarisserver.security.MyPrincipal
@@ -41,11 +42,17 @@ class Orgs(
} }
val org = Organization().apply { val org = Organization().apply {
name = request.name name = request.name
emailAliases = request.emailAliases?.toMutableSet() ?: mutableSetOf()
if (request.allowedTransportModes != null) {
allowedTransportModes = parseTransportModes(request.allowedTransportModes)
}
} }
val saved = orgRepo.save(org) val saved = orgRepo.save(org)
return OrgResponse( return OrgResponse(
id = saved.id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Org id missing"), id = saved.id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Org id missing"),
name = saved.name ?: "" name = saved.name ?: "",
emailAliases = saved.emailAliases.toSet(),
allowedTransportModes = saved.allowedTransportModes.map { it.name }.toSet()
) )
} }
@@ -63,7 +70,9 @@ class Orgs(
} }
return OrgResponse( return OrgResponse(
id = org.id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Org id missing"), id = org.id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Org id missing"),
name = org.name ?: "" name = org.name ?: "",
emailAliases = org.emailAliases.toSet(),
allowedTransportModes = org.allowedTransportModes.map { it.name }.toSet()
) )
} }
@@ -75,4 +84,12 @@ class Orgs(
ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found") ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found")
} }
} }
private fun parseTransportModes(modes: Set<String>): MutableSet<TransportMode> {
return try {
modes.map { TransportMode.valueOf(it) }.toMutableSet()
} catch (_: IllegalArgumentException) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown transport mode")
}
}
} }

View File

@@ -16,6 +16,7 @@ import com.android.trisolarisserver.models.property.PropertyUser
import com.android.trisolarisserver.models.property.PropertyUserId import com.android.trisolarisserver.models.property.PropertyUserId
import com.android.trisolarisserver.models.property.Role import com.android.trisolarisserver.models.property.Role
import com.android.trisolarisserver.security.MyPrincipal import com.android.trisolarisserver.security.MyPrincipal
import com.android.trisolarisserver.models.booking.TransportMode
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.DeleteMapping
@@ -66,7 +67,9 @@ class Properties(
timezone = request.timezone ?: "Asia/Kolkata", timezone = request.timezone ?: "Asia/Kolkata",
currency = request.currency ?: "INR", currency = request.currency ?: "INR",
active = request.active ?: true, active = request.active ?: true,
otaAliases = request.otaAliases?.toMutableSet() ?: mutableSetOf() otaAliases = request.otaAliases?.toMutableSet() ?: mutableSetOf(),
emailAddresses = request.emailAddresses?.toMutableSet() ?: mutableSetOf(),
allowedTransportModes = request.allowedTransportModes?.let { parseTransportModes(it) } ?: mutableSetOf()
) )
val saved = propertyRepo.save(property) val saved = propertyRepo.save(property)
return saved.toResponse() return saved.toResponse()
@@ -211,6 +214,12 @@ class Properties(
if (request.otaAliases != null) { if (request.otaAliases != null) {
property.otaAliases = request.otaAliases.toMutableSet() property.otaAliases = request.otaAliases.toMutableSet()
} }
if (request.emailAddresses != null) {
property.emailAddresses = request.emailAddresses.toMutableSet()
}
if (request.allowedTransportModes != null) {
property.allowedTransportModes = parseTransportModes(request.allowedTransportModes)
}
return propertyRepo.save(property).toResponse() return propertyRepo.save(property).toResponse()
} }
@@ -235,6 +244,14 @@ class Properties(
throw ResponseStatusException(HttpStatus.FORBIDDEN, "Missing role") throw ResponseStatusException(HttpStatus.FORBIDDEN, "Missing role")
} }
} }
private fun parseTransportModes(modes: Set<String>): MutableSet<TransportMode> {
return try {
modes.map { TransportMode.valueOf(it) }.toMutableSet()
} catch (_: IllegalArgumentException) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown transport mode")
}
}
} }
private fun Property.toResponse(): PropertyResponse { private fun Property.toResponse(): PropertyResponse {
@@ -249,7 +266,9 @@ private fun Property.toResponse(): PropertyResponse {
timezone = timezone, timezone = timezone,
currency = currency, currency = currency,
active = active, active = active,
otaAliases = otaAliases.toSet() otaAliases = otaAliases.toSet(),
emailAddresses = emailAddresses.toSet(),
allowedTransportModes = allowedTransportModes.map { it.name }.toSet()
) )
} }

View File

@@ -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()
)
}

View File

@@ -0,0 +1,136 @@
package com.android.trisolarisserver.controller
import com.android.trisolarisserver.component.PropertyAccess
import com.android.trisolarisserver.component.RoomBoardEvents
import com.android.trisolarisserver.controller.dto.RoomChangeRequest
import com.android.trisolarisserver.controller.dto.RoomChangeResponse
import com.android.trisolarisserver.models.room.RoomStay
import com.android.trisolarisserver.models.room.RoomStayChange
import com.android.trisolarisserver.models.property.Role
import com.android.trisolarisserver.repo.AppUserRepo
import com.android.trisolarisserver.repo.RoomRepo
import com.android.trisolarisserver.repo.RoomStayChangeRepo
import com.android.trisolarisserver.repo.RoomStayRepo
import com.android.trisolarisserver.security.MyPrincipal
import org.springframework.http.HttpStatus
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
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.OffsetDateTime
import java.util.UUID
@RestController
@RequestMapping("/properties/{propertyId}/room-stays")
class RoomStayFlow(
private val propertyAccess: PropertyAccess,
private val roomStayRepo: RoomStayRepo,
private val roomStayChangeRepo: RoomStayChangeRepo,
private val roomRepo: RoomRepo,
private val appUserRepo: AppUserRepo,
private val roomBoardEvents: RoomBoardEvents
) {
@PostMapping("/{roomStayId}/change-room")
@ResponseStatus(HttpStatus.CREATED)
@Transactional
fun changeRoom(
@PathVariable propertyId: UUID,
@PathVariable roomStayId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: RoomChangeRequest
): RoomChangeResponse {
val actor = requireActor(propertyId, principal)
val stay = roomStayRepo.findById(roomStayId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Room stay not found")
}
if (stay.property.id != propertyId) {
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room stay not found for property")
}
if (stay.toAt != null) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Room stay already closed")
}
if (request.idempotencyKey.isBlank()) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "idempotencyKey required")
}
if (request.newRoomId == stay.room.id) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "New room is same as current")
}
val existing = roomStayChangeRepo.findByRoomStayIdAndIdempotencyKey(roomStayId, request.idempotencyKey)
if (existing != null) {
return toResponse(existing)
}
val newRoom = roomRepo.findByIdAndPropertyId(request.newRoomId, propertyId)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room not found")
if (!newRoom.active || newRoom.maintenance) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Room not available")
}
val occupied = roomStayRepo.findActiveRoomIds(propertyId, listOf(request.newRoomId))
if (occupied.isNotEmpty()) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Room already occupied")
}
val movedAt = parseOffset(request.movedAt) ?: OffsetDateTime.now()
stay.toAt = movedAt
roomStayRepo.save(stay)
val newStay = RoomStay(
property = stay.property,
booking = stay.booking,
room = newRoom,
fromAt = movedAt,
toAt = null,
createdBy = actor
)
val savedNewStay = roomStayRepo.save(newStay)
val change = RoomStayChange(
property = stay.property,
roomStay = stay,
newRoomStay = savedNewStay,
idempotencyKey = request.idempotencyKey
)
val savedChange = roomStayChangeRepo.save(change)
roomBoardEvents.emit(propertyId)
return toResponse(savedChange)
}
private fun toResponse(change: RoomStayChange): RoomChangeResponse {
return RoomChangeResponse(
oldRoomStayId = change.roomStay.id!!,
newRoomStayId = change.newRoomStay.id!!,
oldRoomId = change.roomStay.room.id!!,
newRoomId = change.newRoomStay.room.id!!,
movedAt = change.newRoomStay.fromAt.toString()
)
}
private fun parseOffset(value: String?): OffsetDateTime? {
if (value.isNullOrBlank()) return null
return try {
OffsetDateTime.parse(value.trim())
} catch (_: Exception) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid timestamp")
}
}
private fun requireActor(propertyId: UUID, principal: MyPrincipal?): com.android.trisolarisserver.models.property.AppUser {
if (principal == null) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
}
propertyAccess.requireMember(propertyId, principal.userId)
propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN, Role.MANAGER, Role.STAFF)
return appUserRepo.findById(principal.userId).orElseThrow {
ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found")
}
}
}

View File

@@ -1,6 +1,8 @@
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.component.RoomBoardEvents
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 +27,9 @@ 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 org.springframework.web.servlet.mvc.method.annotation.SseEmitter
import java.time.LocalDate
import java.time.ZoneId
import java.util.UUID import java.util.UUID
@RestController @RestController
@@ -35,7 +40,8 @@ class Rooms(
private val roomStayRepo: RoomStayRepo, private val roomStayRepo: RoomStayRepo,
private val propertyRepo: PropertyRepo, private val propertyRepo: PropertyRepo,
private val roomTypeRepo: RoomTypeRepo, private val roomTypeRepo: RoomTypeRepo,
private val propertyUserRepo: PropertyUserRepo private val propertyUserRepo: PropertyUserRepo,
private val roomBoardEvents: RoomBoardEvents
) { ) {
@GetMapping @GetMapping
@@ -85,6 +91,16 @@ class Rooms(
return if (isAgentOnly(roles)) mapped.filter { it.status == RoomBoardStatus.FREE } else mapped return if (isAgentOnly(roles)) mapped.filter { it.status == RoomBoardStatus.FREE } else mapped
} }
@GetMapping("/board/stream")
fun roomBoardStream(
@PathVariable propertyId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
): SseEmitter {
requirePrincipal(principal)
propertyAccess.requireMember(propertyId, principal!!.userId)
return roomBoardEvents.subscribe(propertyId)
}
@GetMapping("/availability") @GetMapping("/availability")
fun roomAvailability( fun roomAvailability(
@PathVariable propertyId: UUID, @PathVariable propertyId: UUID,
@@ -106,6 +122,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(
@@ -137,7 +190,9 @@ class Rooms(
notes = request.notes notes = request.notes
) )
return roomRepo.save(room).toRoomResponse() val saved = roomRepo.save(room).toRoomResponse()
roomBoardEvents.emit(propertyId)
return saved
} }
@PutMapping("/{roomId}") @PutMapping("/{roomId}")
@@ -168,7 +223,9 @@ class Rooms(
room.maintenance = request.maintenance room.maintenance = request.maintenance
room.notes = request.notes room.notes = request.notes
return roomRepo.save(room).toRoomResponse() val saved = roomRepo.save(room).toRoomResponse()
roomBoardEvents.emit(propertyId)
return saved
} }
private fun requirePrincipal(principal: MyPrincipal?) { private fun requirePrincipal(principal: MyPrincipal?) {
@@ -182,6 +239,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 {

View File

@@ -0,0 +1,52 @@
package com.android.trisolarisserver.controller
import com.android.trisolarisserver.component.PropertyAccess
import com.android.trisolarisserver.controller.dto.TransportModeStatusResponse
import com.android.trisolarisserver.models.booking.TransportMode
import com.android.trisolarisserver.repo.PropertyRepo
import com.android.trisolarisserver.security.MyPrincipal
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.RequestMapping
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.server.ResponseStatusException
import java.util.UUID
@RestController
@RequestMapping("/properties/{propertyId}/transport-modes")
class TransportModes(
private val propertyAccess: PropertyAccess,
private val propertyRepo: PropertyRepo
) {
@GetMapping
fun list(
@PathVariable propertyId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
): List<TransportModeStatusResponse> {
requirePrincipal(principal)
propertyAccess.requireMember(propertyId, principal!!.userId)
val property = propertyRepo.findById(propertyId).orElseThrow {
ResponseStatusException(org.springframework.http.HttpStatus.NOT_FOUND, "Property not found")
}
val allowed = when {
property.allowedTransportModes.isNotEmpty() -> property.allowedTransportModes
property.org.allowedTransportModes.isNotEmpty() -> property.org.allowedTransportModes
else -> TransportMode.entries.toSet()
}
return TransportMode.entries.map { mode ->
TransportModeStatusResponse(
mode = mode.name,
enabled = allowed.contains(mode)
)
}
}
private fun requirePrincipal(principal: MyPrincipal?) {
if (principal == null) {
throw ResponseStatusException(org.springframework.http.HttpStatus.UNAUTHORIZED, "Missing principal")
}
}
}

View File

@@ -0,0 +1,79 @@
package com.android.trisolarisserver.controller.dto
import java.util.UUID
data class BookingCheckInRequest(
val roomIds: List<UUID>,
val checkInAt: String? = null,
val transportMode: String? = null,
val transportVehicleNumber: String? = null,
val notes: String? = null
)
data class BookingCheckOutRequest(
val checkOutAt: String? = null,
val notes: String? = null
)
data class BookingCancelRequest(
val cancelledAt: String? = null,
val reason: String? = null
)
data class BookingNoShowRequest(
val noShowAt: String? = null,
val reason: String? = null
)
data class RoomChangeRequest(
val newRoomId: UUID,
val movedAt: String? = null,
val idempotencyKey: String
)
data class RoomChangeResponse(
val oldRoomStayId: UUID,
val newRoomStayId: UUID,
val oldRoomId: UUID,
val newRoomId: UUID,
val movedAt: String
)
data class RoomStayPreAssignRequest(
val roomId: UUID,
val fromAt: String,
val toAt: String,
val notes: String? = null
)
data class IssueCardRequest(
val cardId: String,
val cardIndex: Int,
val issuedAt: String? = null,
val expiresAt: String
)
data class IssuedCardResponse(
val id: UUID,
val propertyId: UUID,
val roomId: UUID,
val roomStayId: UUID,
val cardId: String,
val cardIndex: Int,
val issuedAt: String,
val expiresAt: String,
val issuedByUserId: UUID?,
val revokedAt: String?
)
data class CardPrepareRequest(
val expiresAt: String? = null
)
data class CardPrepareResponse(
val cardIndex: Int,
val key: String,
val timeData: String,
val issuedAt: String,
val expiresAt: String
)

View File

@@ -0,0 +1,21 @@
package com.android.trisolarisserver.controller.dto
import java.util.UUID
data class GuestRatingCreateRequest(
val bookingId: UUID,
val score: String,
val notes: String? = null
)
data class GuestRatingResponse(
val id: UUID,
val orgId: UUID,
val propertyId: UUID,
val guestId: UUID,
val bookingId: UUID,
val score: String,
val notes: String?,
val createdAt: String,
val createdByUserId: UUID?
)

View File

@@ -3,12 +3,16 @@ package com.android.trisolarisserver.controller.dto
import java.util.UUID import java.util.UUID
data class OrgCreateRequest( data class OrgCreateRequest(
val name: String val name: String,
val emailAliases: Set<String>? = null,
val allowedTransportModes: Set<String>? = null
) )
data class OrgResponse( data class OrgResponse(
val id: UUID, val id: UUID,
val name: String val name: String,
val emailAliases: Set<String>,
val allowedTransportModes: Set<String>
) )
data class PropertyCreateRequest( data class PropertyCreateRequest(
@@ -18,7 +22,9 @@ data class PropertyCreateRequest(
val timezone: String? = null, val timezone: String? = null,
val currency: String? = null, val currency: String? = null,
val active: Boolean? = null, val active: Boolean? = null,
val otaAliases: Set<String>? = null val otaAliases: Set<String>? = null,
val emailAddresses: Set<String>? = null,
val allowedTransportModes: Set<String>? = null
) )
data class PropertyUpdateRequest( data class PropertyUpdateRequest(
@@ -28,7 +34,9 @@ data class PropertyUpdateRequest(
val timezone: String? = null, val timezone: String? = null,
val currency: String? = null, val currency: String? = null,
val active: Boolean? = null, val active: Boolean? = null,
val otaAliases: Set<String>? = null val otaAliases: Set<String>? = null,
val emailAddresses: Set<String>? = null,
val allowedTransportModes: Set<String>? = null
) )
data class PropertyResponse( data class PropertyResponse(
@@ -40,7 +48,29 @@ data class PropertyResponse(
val timezone: String, val timezone: String,
val currency: String, val currency: String,
val active: Boolean, val active: Boolean,
val otaAliases: Set<String> val otaAliases: Set<String>,
val emailAddresses: Set<String>,
val allowedTransportModes: Set<String>
)
data class GuestResponse(
val id: UUID,
val orgId: UUID,
val name: String?,
val phoneE164: String?,
val nationality: String?,
val addressText: String?,
val vehicleNumbers: Set<String>,
val averageScore: Double?
)
data class GuestVehicleRequest(
val vehicleNumber: String
)
data class TransportModeStatusResponse(
val mode: String,
val enabled: Boolean
) )
data class UserResponse( data class UserResponse(

View File

@@ -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,

View File

@@ -0,0 +1,27 @@
package com.android.trisolarisserver.db.repo
import com.android.trisolarisserver.models.booking.GuestRating
import org.springframework.data.jpa.repository.Query
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.repository.query.Param
import java.util.UUID
interface GuestRatingRepo : JpaRepository<GuestRating, UUID> {
fun findByGuestIdOrderByCreatedAtDesc(guestId: UUID): List<GuestRating>
fun existsByGuestIdAndBookingId(guestId: UUID, bookingId: UUID): Boolean
@Query(
"""
select gr.guest.id,
avg(case gr.score
when com.android.trisolarisserver.models.booking.GuestRatingScore.GOOD then 3
when com.android.trisolarisserver.models.booking.GuestRatingScore.OK then 2
when com.android.trisolarisserver.models.booking.GuestRatingScore.TROUBLE then 1
else null end)
from GuestRating gr
where gr.guest.id in :guestIds
group by gr.guest.id
"""
)
fun findAverageScoreByGuestIds(@Param("guestIds") guestIds: Collection<UUID>): List<Array<Any>>
}

View File

@@ -7,6 +7,7 @@ import java.util.UUID
interface InboundEmailRepo : JpaRepository<InboundEmail, UUID> { interface InboundEmailRepo : JpaRepository<InboundEmail, UUID> {
fun findByMessageId(messageId: String): InboundEmail? fun findByMessageId(messageId: String): InboundEmail?
fun findByPropertyIdAndOtaBookingId(propertyId: UUID, otaBookingId: String): InboundEmail? fun findByPropertyIdAndOtaBookingId(propertyId: UUID, otaBookingId: String): InboundEmail?
fun findByIdAndPropertyId(id: UUID, propertyId: UUID): InboundEmail?
fun existsByMessageId(messageId: String): Boolean fun existsByMessageId(messageId: String): Boolean
fun existsByPropertyIdAndOtaBookingId(propertyId: UUID, otaBookingId: String): Boolean fun existsByPropertyIdAndOtaBookingId(propertyId: UUID, otaBookingId: String): Boolean
} }

View File

@@ -49,6 +49,31 @@ class Booking(
@Column(name = "expected_checkout_at", columnDefinition = "timestamptz") @Column(name = "expected_checkout_at", columnDefinition = "timestamptz")
var expectedCheckoutAt: OffsetDateTime? = null, var expectedCheckoutAt: OffsetDateTime? = null,
@Column(name = "email_audit_pdf_url")
var emailAuditPdfUrl: String? = null,
@Enumerated(EnumType.STRING)
@Column(name = "transport_mode")
var transportMode: TransportMode? = null,
@Column(name = "transport_vehicle_number")
var transportVehicleNumber: String? = null,
@Column(name = "adult_count")
var adultCount: Int? = null,
@Column(name = "child_count")
var childCount: Int? = null,
@Column(name = "male_count")
var maleCount: Int? = null,
@Column(name = "female_count")
var femaleCount: Int? = null,
@Column(name = "total_guest_count")
var totalGuestCount: Int? = null,
var notes: String? = null, var notes: String? = null,
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)

View File

@@ -0,0 +1,61 @@
package com.android.trisolarisserver.models.booking
import com.android.trisolarisserver.models.property.AppUser
import com.android.trisolarisserver.models.property.Organization
import com.android.trisolarisserver.models.property.Property
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
import jakarta.persistence.FetchType
import jakarta.persistence.GeneratedValue
import jakarta.persistence.Id
import jakarta.persistence.JoinColumn
import jakarta.persistence.ManyToOne
import jakarta.persistence.Table
import jakarta.persistence.UniqueConstraint
import java.time.OffsetDateTime
import java.util.UUID
@Entity
@Table(
name = "guest_rating",
uniqueConstraints = [
UniqueConstraint(columnNames = ["guest_id", "booking_id"])
]
)
class GuestRating(
@Id
@GeneratedValue
@Column(columnDefinition = "uuid")
val id: UUID? = null,
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "org_id", nullable = false)
var org: Organization,
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "property_id", nullable = false)
var property: Property,
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "guest_id", nullable = false)
var guest: Guest,
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "booking_id", nullable = false)
var booking: Booking,
@Enumerated(EnumType.STRING)
@Column(nullable = false)
var score: GuestRatingScore,
var notes: String? = null,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "created_by")
var createdBy: AppUser? = null,
@Column(name = "created_at", columnDefinition = "timestamptz")
val createdAt: OffsetDateTime = OffsetDateTime.now()
)

View File

@@ -0,0 +1,5 @@
package com.android.trisolarisserver.models.booking
enum class GuestRatingScore {
GOOD, OK, TROUBLE
}

View File

@@ -0,0 +1,32 @@
package com.android.trisolarisserver.models.booking
import com.android.trisolarisserver.models.property.Organization
import jakarta.persistence.*
import java.time.OffsetDateTime
import java.util.UUID
@Entity
@Table(
name = "guest_vehicle",
uniqueConstraints = [UniqueConstraint(columnNames = ["org_id", "vehicle_number"])]
)
class GuestVehicle(
@Id
@GeneratedValue
@Column(columnDefinition = "uuid")
val id: UUID? = null,
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "org_id", nullable = false)
var org: Organization,
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "guest_id", nullable = false)
var guest: Guest,
@Column(name = "vehicle_number", nullable = false)
var vehicleNumber: String,
@Column(name = "created_at", nullable = false, columnDefinition = "timestamptz")
val createdAt: OffsetDateTime = OffsetDateTime.now()
)

View File

@@ -49,6 +49,9 @@ class InboundEmail(
@Column(name = "raw_pdf_path") @Column(name = "raw_pdf_path")
var rawPdfPath: String? = null, var rawPdfPath: String? = null,
@Column(name = "raw_eml_path")
var rawEmlPath: String? = null,
@Column(name = "extracted_data", columnDefinition = "jsonb") @Column(name = "extracted_data", columnDefinition = "jsonb")
var extractedData: String? = null, var extractedData: String? = null,

View File

@@ -0,0 +1,12 @@
package com.android.trisolarisserver.models.booking
enum class TransportMode {
CAR,
BIKE,
TRAIN,
PLANE,
BUS,
FOOT,
CYCLE,
OTHER
}

View File

@@ -14,5 +14,24 @@ class Organization {
@Column(nullable = false) @Column(nullable = false)
var name: String? = null var name: String? = null
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(
name = "org_email_alias",
joinColumns = [JoinColumn(name = "org_id")]
)
@Column(name = "email", nullable = false)
var emailAliases: MutableSet<String> = mutableSetOf()
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(
name = "org_transport_mode",
joinColumns = [JoinColumn(name = "org_id")]
)
@Column(name = "mode", nullable = false)
@Enumerated(EnumType.STRING)
var allowedTransportModes: MutableSet<com.android.trisolarisserver.models.booking.TransportMode> =
mutableSetOf()
@Column(name = "created_at", nullable = false, columnDefinition = "timestamptz") @Column(name = "created_at", nullable = false, columnDefinition = "timestamptz")
val createdAt: OffsetDateTime = OffsetDateTime.now() } val createdAt: OffsetDateTime = OffsetDateTime.now()
}

View File

@@ -37,6 +37,14 @@ class Property(
@Column(name = "address_text") @Column(name = "address_text")
var addressText: String? = null, var addressText: String? = null,
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(
name = "property_email_address",
joinColumns = [JoinColumn(name = "property_id")]
)
@Column(name = "email", nullable = false)
var emailAddresses: MutableSet<String> = mutableSetOf(),
@ElementCollection(fetch = FetchType.EAGER) @ElementCollection(fetch = FetchType.EAGER)
@CollectionTable( @CollectionTable(
name = "property_email_alias", name = "property_email_alias",
@@ -45,6 +53,16 @@ class Property(
@Column(name = "alias", nullable = false) @Column(name = "alias", nullable = false)
var otaAliases: MutableSet<String> = mutableSetOf(), var otaAliases: MutableSet<String> = mutableSetOf(),
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(
name = "property_transport_mode",
joinColumns = [JoinColumn(name = "property_id")]
)
@Column(name = "mode", nullable = false)
@Enumerated(EnumType.STRING)
var allowedTransportModes: MutableSet<com.android.trisolarisserver.models.booking.TransportMode> =
mutableSetOf(),
@Column(name = "created_at", nullable = false, columnDefinition = "timestamptz") @Column(name = "created_at", nullable = false, columnDefinition = "timestamptz")
val createdAt: OffsetDateTime = OffsetDateTime.now() val createdAt: OffsetDateTime = OffsetDateTime.now()
) )

View File

@@ -0,0 +1,56 @@
package com.android.trisolarisserver.models.room
import com.android.trisolarisserver.models.property.AppUser
import com.android.trisolarisserver.models.property.Property
import jakarta.persistence.*
import java.time.OffsetDateTime
import java.util.UUID
@Entity
@Table(
name = "issued_card",
uniqueConstraints = [
UniqueConstraint(columnNames = ["property_id", "card_index"])
],
indexes = [
Index(name = "idx_issued_card_room_stay", columnList = "room_stay_id"),
Index(name = "idx_issued_card_card_id", columnList = "card_id")
]
)
class IssuedCard(
@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,
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "room_stay_id", nullable = false)
var roomStay: RoomStay,
@Column(name = "card_id", nullable = false)
var cardId: String,
@Column(name = "card_index", nullable = false)
var cardIndex: Int,
@Column(name = "issued_at", nullable = false, columnDefinition = "timestamptz")
var issuedAt: OffsetDateTime,
@Column(name = "expires_at", nullable = false, columnDefinition = "timestamptz")
var expiresAt: OffsetDateTime,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "issued_by")
var issuedBy: AppUser? = null,
@Column(name = "revoked_at", columnDefinition = "timestamptz")
var revokedAt: OffsetDateTime? = null
)

View File

@@ -0,0 +1,28 @@
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 = "property_card_counter",
uniqueConstraints = [UniqueConstraint(columnNames = ["property_id"])]
)
class PropertyCardCounter(
@Id
@GeneratedValue
@Column(columnDefinition = "uuid")
val id: UUID? = null,
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "property_id", nullable = false)
var property: Property,
@Column(name = "next_index", nullable = false)
var nextIndex: Int = 1,
@Column(name = "updated_at", nullable = false, columnDefinition = "timestamptz")
var updatedAt: OffsetDateTime = OffsetDateTime.now()
)

View File

@@ -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()
)

View File

@@ -0,0 +1,36 @@
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_stay_change",
uniqueConstraints = [UniqueConstraint(columnNames = ["room_stay_id", "idempotency_key"])]
)
class RoomStayChange(
@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_stay_id", nullable = false)
var roomStay: RoomStay,
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "new_room_stay_id", nullable = false)
var newRoomStay: RoomStay,
@Column(name = "idempotency_key", nullable = false)
var idempotencyKey: String,
@Column(name = "created_at", nullable = false, columnDefinition = "timestamptz")
val createdAt: OffsetDateTime = OffsetDateTime.now()
)

View File

@@ -0,0 +1,11 @@
package com.android.trisolarisserver.repo
import com.android.trisolarisserver.models.booking.GuestVehicle
import org.springframework.data.jpa.repository.JpaRepository
import java.util.UUID
interface GuestVehicleRepo : JpaRepository<GuestVehicle, UUID> {
fun findByOrgIdAndVehicleNumberIgnoreCase(orgId: UUID, vehicleNumber: String): GuestVehicle?
fun findByGuestIdIn(guestIds: List<UUID>): List<GuestVehicle>
fun existsByOrgIdAndVehicleNumberIgnoreCase(orgId: UUID, vehicleNumber: String): Boolean
}

View File

@@ -0,0 +1,36 @@
package com.android.trisolarisserver.repo
import com.android.trisolarisserver.models.room.IssuedCard
import org.springframework.data.jpa.repository.JpaRepository
import java.util.UUID
interface IssuedCardRepo : JpaRepository<IssuedCard, UUID> {
fun findByRoomStayIdOrderByIssuedAtDesc(roomStayId: UUID): List<IssuedCard>
fun findByIdAndPropertyId(id: UUID, propertyId: UUID): IssuedCard?
@org.springframework.data.jpa.repository.Query("""
select case when count(c) > 0 then true else false end
from IssuedCard c
where c.property.id = :propertyId
and c.room.id = :roomId
and c.revokedAt is null
and c.expiresAt > :now
""")
fun existsActiveForRoom(
@org.springframework.data.repository.query.Param("propertyId") propertyId: UUID,
@org.springframework.data.repository.query.Param("roomId") roomId: UUID,
@org.springframework.data.repository.query.Param("now") now: java.time.OffsetDateTime
): Boolean
@org.springframework.data.jpa.repository.Query("""
select case when count(c) > 0 then true else false end
from IssuedCard c
where c.roomStay.id = :roomStayId
and c.revokedAt is null
and c.expiresAt > :now
""")
fun existsActiveForRoomStay(
@org.springframework.data.repository.query.Param("roomStayId") roomStayId: UUID,
@org.springframework.data.repository.query.Param("now") now: java.time.OffsetDateTime
): Boolean
}

View File

@@ -0,0 +1,17 @@
package com.android.trisolarisserver.repo
import com.android.trisolarisserver.models.room.PropertyCardCounter
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Lock
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import java.util.UUID
import jakarta.persistence.LockModeType
interface PropertyCardCounterRepo : JpaRepository<PropertyCardCounter, UUID> {
fun findByPropertyId(propertyId: UUID): PropertyCardCounter?
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select c from PropertyCardCounter c where c.property.id = :propertyId")
fun findByPropertyIdForUpdate(@Param("propertyId") propertyId: UUID): PropertyCardCounter?
}

View File

@@ -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?
}

View File

@@ -0,0 +1,9 @@
package com.android.trisolarisserver.repo
import com.android.trisolarisserver.models.room.RoomStayChange
import org.springframework.data.jpa.repository.JpaRepository
import java.util.UUID
interface RoomStayChangeRepo : JpaRepository<RoomStayChange, UUID> {
fun findByRoomStayIdAndIdempotencyKey(roomStayId: UUID, idempotencyKey: String): RoomStayChange?
}

View File

@@ -14,4 +14,52 @@ 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>
@Query("""
select rs
from RoomStay rs
where rs.booking.id = :bookingId
and rs.toAt is null
""")
fun findActiveByBookingId(@Param("bookingId") bookingId: UUID): List<RoomStay>
@Query("""
select rs.room.id
from RoomStay rs
where rs.property.id = :propertyId
and rs.room.id in :roomIds
and rs.toAt is null
""")
fun findActiveRoomIds(
@Param("propertyId") propertyId: UUID,
@Param("roomIds") roomIds: List<UUID>
): List<UUID>
@Query("""
select case when count(rs) > 0 then true else false end
from RoomStay rs
where rs.property.id = :propertyId
and rs.room.id = :roomId
and rs.fromAt < :toAt
and (rs.toAt is null or rs.toAt > :fromAt)
""")
fun existsOverlap(
@Param("propertyId") propertyId: UUID,
@Param("roomId") roomId: UUID,
@Param("fromAt") fromAt: java.time.OffsetDateTime,
@Param("toAt") toAt: java.time.OffsetDateTime
): Boolean
} }

View File

@@ -49,6 +49,8 @@ class EmailIngestionService(
@Value("\${mail.imap.protocol:imaps}") @Value("\${mail.imap.protocol:imaps}")
private val protocol: String private val protocol: String
) { ) {
@Value("\${storage.emails.publicBaseUrl}")
private val publicBaseUrl: String = ""
private val running = AtomicBoolean(false) private val running = AtomicBoolean(false)
@Value("\${mail.imap.enabled:false}") @Value("\${mail.imap.enabled:false}")
private val enabled: Boolean = false private val enabled: Boolean = false
@@ -86,9 +88,10 @@ class EmailIngestionService(
val subject = message.subject val subject = message.subject
val from = message.from?.firstOrNull()?.toString() val from = message.from?.firstOrNull()?.toString()
val recipients = extractRecipients(message)
val receivedAt = message.receivedDate?.toInstant()?.atOffset(OffsetDateTime.now().offset) val receivedAt = message.receivedDate?.toInstant()?.atOffset(OffsetDateTime.now().offset)
val body = extractText(message) val body = extractText(message)
val property = matchProperty(subject, body) val property = matchProperty(subject, body, recipients)
val inbound = InboundEmail( val inbound = InboundEmail(
property = property, property = property,
@@ -98,6 +101,10 @@ class EmailIngestionService(
receivedAt = receivedAt receivedAt = receivedAt
) )
val rawBytes = extractRawMessage(message)
if (rawBytes != null) {
inbound.rawEmlPath = emailStorage.storeEml(property?.id, messageId, rawBytes)
}
inbound.rawPdfPath = emailStorage.storePdf(property?.id, messageId, subject, body) inbound.rawPdfPath = emailStorage.storePdf(property?.id, messageId, subject, body)
inboundEmailRepo.save(inbound) inboundEmailRepo.save(inbound)
@@ -108,56 +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 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<String, String> { private fun extractBookingDetails(body: String): Map<String, String> {
@@ -214,7 +172,8 @@ class EmailIngestionService(
property: Property, property: Property,
guest: Guest, guest: Guest,
extracted: Map<String, String>, extracted: Map<String, String>,
sourceBookingId: String sourceBookingId: String,
emailAuditPdfUrl: String?
): Booking { ): Booking {
val zone = ZoneId.of(property.timezone) val zone = ZoneId.of(property.timezone)
val checkin = parsedDate(extracted["checkinDate"], zone) val checkin = parsedDate(extracted["checkinDate"], zone)
@@ -226,11 +185,71 @@ class EmailIngestionService(
source = extracted["otaSource"]?.takeIf { !it.contains("NONE", true) } ?: "OTA", source = extracted["otaSource"]?.takeIf { !it.contains("NONE", true) } ?: "OTA",
sourceBookingId = sourceBookingId, sourceBookingId = sourceBookingId,
expectedCheckinAt = checkin, expectedCheckinAt = checkin,
expectedCheckoutAt = checkout expectedCheckoutAt = checkout,
emailAuditPdfUrl = emailAuditPdfUrl
) )
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
@@ -253,10 +272,20 @@ class EmailIngestionService(
} }
} }
private fun matchProperty(subject: String?, body: String): Property? { private fun matchProperty(subject: String?, body: String, recipients: List<String>): Property? {
val haystack = "${subject ?: ""}\n$body".lowercase() val haystack = "${subject ?: ""}\n$body".lowercase()
val properties = propertyRepo.findAll() val properties = propertyRepo.findAll()
val matches = properties.filter { property -> val matches = properties.filter { property ->
if (recipients.isNotEmpty()) {
val propertyEmails = property.emailAddresses.map { it.lowercase() }.toSet()
if (propertyEmails.isNotEmpty() && recipients.any { it.lowercase() in propertyEmails }) {
return@filter true
}
val orgEmails = property.org.emailAliases.map { it.lowercase() }.toSet()
if (orgEmails.isNotEmpty() && recipients.any { it.lowercase() in orgEmails }) {
return@filter true
}
}
val aliases = mutableSetOf<String>() val aliases = mutableSetOf<String>()
aliases.add(property.name) aliases.add(property.name)
aliases.add(property.code) aliases.add(property.code)
@@ -267,6 +296,15 @@ class EmailIngestionService(
return if (matches.size == 1) matches.first() else null return if (matches.size == 1) matches.first() else null
} }
private fun extractRecipients(message: Message): List<String> {
val list = mutableListOf<String>()
val to = message.getRecipients(Message.RecipientType.TO)
val cc = message.getRecipients(Message.RecipientType.CC)
(to ?: emptyArray()).forEach { list.add(it.toString()) }
(cc ?: emptyArray()).forEach { list.add(it.toString()) }
return list
}
private fun extractText(message: Message): String { private fun extractText(message: Message): String {
return try { return try {
val content = message.content val content = message.content
@@ -280,6 +318,16 @@ class EmailIngestionService(
} }
} }
private fun extractRawMessage(message: Message): ByteArray? {
return try {
val out = java.io.ByteArrayOutputStream()
message.writeTo(out)
out.toByteArray()
} catch (_: Exception) {
null
}
}
private fun extractFromMultipart(multipart: Multipart): String { private fun extractFromMultipart(multipart: Multipart): String {
var text: String? = null var text: String? = null
var html: String? = null var html: String? = null

View File

@@ -10,6 +10,9 @@ storage.documents.publicBaseUrl=https://api.hoteltrisolaris.in
storage.documents.tokenSecret=change-me 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.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