Compare commits

..

2 Commits

Author SHA1 Message Date
deploy
4998701f84 Make CI build verbose
Some checks failed
build / deploy (push) Has been cancelled
build / build (push) Has been cancelled
2026-01-26 17:12:45 +05:30
deploy
dfe44927ef Fix actions runner label and JDK
Some checks failed
build / build (push) Waiting to run
build / deploy (push) Has been cancelled
2026-01-26 17:00:57 +05:30
186 changed files with 2535 additions and 17803 deletions

View File

@@ -1,32 +1,35 @@
name: build-and-deploy
name: build
on:
push:
branches: [ "master" ]
branches: [ "**" ]
pull_request:
branches: [ "**" ]
jobs:
build-deploy:
build:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up JDK 21
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
java-version: "17"
cache: gradle
- name: Build (skip tests)
- name: Build (verbose)
env:
GRADLE_OPTS: "-Dorg.gradle.internal.http.connectionTimeout=60000 -Dorg.gradle.internal.http.socketTimeout=60000 -Dorg.gradle.workers.max=6"
run: ./gradlew build -x test --info --stacktrace
GRADLE_OPTS: "-Dorg.gradle.internal.http.connectionTimeout=60000 -Dorg.gradle.internal.http.socketTimeout=60000"
run: ./gradlew build -x test --no-daemon --info --stacktrace
- name: Deploy jar and restart
deploy:
runs-on: ubuntu-latest
needs: build
steps:
- name: Trigger deploy webhook
run: |
set -e
mkdir -p /opt/deploy/TrisolarisServer/build/libs
cp -f build/libs/*.jar /opt/deploy/TrisolarisServer/build/libs/
sudo systemctl restart TrisolarisServer.service
curl -sS -X POST http://127.0.0.1:9000/hooks/deploy-trisolarisserver

114
AGENTS.md
View File

@@ -22,7 +22,7 @@ Core principles
- 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.
- AppUser is global; access granted per property.
- Users belong to org; access granted per property.
Immutable rules
- Use Kotlin only; no microservices.
@@ -40,33 +40,25 @@ Repository
- Root: /home/androidlover5842/IdeaProjects/TrisolarisServer
- Entry: src/main/kotlin/com/android/trisolarisserver/TrisolarisServerApplication.kt
- Scheduling enabled (@EnableScheduling)
- Package layout (domain subpackages; keep top-level grouping):
- controller/{auth,booking,guest,room,rate,property,payment,card,email,document,common,system,assets,transport,razorpay}
- controller/dto/{booking,guest,payment,property,rate,room,razorpay}
- repo/{booking,guest,room,rate,property,card,email,razorpay}
- component/{ai,auth,booking,document,geo,room,sse,storage,razorpay}
- config/{core,db,booking,room,rate,guest,payment,card,razorpay}
- service/email
Security/Auth
- Firebase Admin auth for every request; Firebase UID required.
- /auth/verify and /auth/me.
Domain entities
- Organization: name, emailAliases, allowedTransportModes.
- Property: code, name, addressText, emailAddresses, otaAliases, allowedTransportModes.
- AppUser (global, superAdmin), PropertyUser (roles per property).
- RoomType: code/name/occupancy + otaAliases + defaultRate.
- AppUser, PropertyUser (roles per property).
- RoomType: code/name/occupancy + otaAliases.
- Room: roomNumber, floor, hasNfc, active, maintenance, notes.
- Booking: status, expected check-in/out, emailAuditPdfUrl, transportMode.
- Guest (property-scoped).
- RoomStay (rate fields stored on stay).
- Booking: status, expected check-in/out, emailAuditPdfUrl, transportMode, transportVehicleNumber.
- Guest (org-scoped).
- RoomStay.
- RoomStayChange (idempotent room move).
- IssuedCard (cardId, cardIndex, issuedAt, expiresAt, issuedBy, revokedAt).
- PropertyCardCounter (per-property cardIndex counter).
- RatePlan + RateCalendar.
- Payment (ledger).
- GuestDocument (files + AI-extracted json).
- GuestVehicle (property-scoped vehicle numbers).
- GuestVehicle (org-scoped vehicle numbers).
- InboundEmail (audit PDF + raw EML, extracted json, status).
- RoomImage (original + thumbnail).
@@ -76,10 +68,14 @@ Auth
- /auth/verify
- /auth/me
Properties / Users
- POST /properties (creator becomes ADMIN on that property)
- GET /properties (super admin gets all; others get memberships)
Organizations / Properties / Users
- POST /orgs
- GET /orgs/{orgId}
- POST /orgs/{orgId}/properties
- GET /orgs/{orgId}/properties
- PUT /properties/{propertyId}
- 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)
@@ -90,74 +86,39 @@ Rooms / inventory
- /properties/{propertyId}/rooms/board/stream (SSE)
- /properties/{propertyId}/rooms/availability
- /properties/{propertyId}/rooms/availability-range?from=YYYY-MM-DD&to=YYYY-MM-DD
- Public availability:
- GET /properties/{propertyId}/rooms/available
- GET /properties/{propertyId}/rooms/by-type/{roomTypeCode}?availableOnly=true|false
Room types
- POST /properties/{propertyId}/room-types
- GET /properties/{propertyId}/room-types
- GET /properties/{propertyId}/room-types/{roomTypeCode}/rate?date=YYYY-MM-DD&ratePlanCode=optional
- PUT /properties/{propertyId}/room-types/{roomTypeId}
- DELETE /properties/{propertyId}/room-types/{roomTypeId}
Properties
Properties / Orgs
- Property create/update accepts addressText, otaAliases, emailAddresses, allowedTransportModes.
- Org create/get returns emailAliases + allowedTransportModes.
Booking flow
- POST /properties/{propertyId}/bookings (create booking)
- /properties/{propertyId}/bookings/{bookingId}/check-in/bulk (creates RoomStay rows with per-stay rates)
- /properties/{propertyId}/bookings/{bookingId}/check-in (creates RoomStay rows)
- /properties/{propertyId}/bookings/{bookingId}/check-out (closes RoomStay)
- /properties/{propertyId}/bookings/{bookingId}/room-stays/{roomStayId}/check-out (closes specific stay; single-stay booking auto-closes booking)
- /properties/{propertyId}/bookings/{bookingId}/cancel
- /properties/{propertyId}/bookings/{bookingId}/no-show
- /properties/{propertyId}/bookings/{bookingId}/room-requests (room-type quantity reservation)
- /properties/{propertyId}/bookings/{bookingId}/room-requests/{requestId} (cancel reservation)
- /properties/{propertyId}/room-stays/{roomStayId}/void (soft-void active stay)
- /properties/{propertyId}/cancellation-policy (get/update policy)
- /properties/{propertyId}/bookings/{bookingId}/room-stays (pre-assign RoomStay with date range)
- /properties/{propertyId}/room-stays/{roomStayId}/change-room (idempotent via RoomStayChange)
Card issuing
- /properties/{propertyId}/room-stays/{roomStayId}/cards/prepare -> returns cardIndex + sector0 payload
- /properties/{propertyId}/room-stays/{roomStayId}/cards -> store issued card
- /properties/{propertyId}/room-stays/{roomStayId}/cards (list)
- /properties/{propertyId}/room-stays/cards/{cardIndex}/revoke (ADMIN; MANAGER allowed only for temp cards)
- Temp cards (room-only, 7 min expiry):
- POST /properties/{propertyId}/rooms/{roomId}/cards/prepare-temp
- POST /properties/{propertyId}/rooms/{roomId}/cards/temp
- /properties/{propertyId}/room-stays/cards/{cardId}/revoke (ADMIN only)
Guest APIs
- POST /properties/{propertyId}/guests
- /properties/{propertyId}/guests/search?phone=... or ?vehicleNumber=...
- /properties/{propertyId}/guests/{guestId}/vehicles (add vehicle)
- POST /properties/{propertyId}/guests/{guestId}/signature
- GET /properties/{propertyId}/guests/{guestId}/signature/file
Room stays
- POST /properties/{propertyId}/room-stays/{roomStayId}/change-rate
Rate plans
- POST /properties/{propertyId}/rate-plans
- GET /properties/{propertyId}/rate-plans
- PUT /properties/{propertyId}/rate-plans/{ratePlanId}
- DELETE /properties/{propertyId}/rate-plans/{ratePlanId}
- POST /properties/{propertyId}/rate-plans/{ratePlanId}/calendar
- GET /properties/{propertyId}/rate-plans/{ratePlanId}/calendar?from=YYYY-MM-DD&to=YYYY-MM-DD
- DELETE /properties/{propertyId}/rate-plans/{ratePlanId}/calendar/{rateDate}
Payments
- POST /properties/{propertyId}/bookings/{bookingId}/payments
- GET /properties/{propertyId}/bookings/{bookingId}/payments
- GET /properties/{propertyId}/bookings/{bookingId}/balance
- DELETE /properties/{propertyId}/bookings/{bookingId}/payments/{paymentId} (ADMIN/super admin; CASH only; booking OPEN or CHECKED_IN)
Guest documents
- /properties/{propertyId}/guests/{guestId}/documents (upload/list)
- /properties/{propertyId}/guests/{guestId}/documents/{documentId}/file
- AI extraction with strict system prompt.
- DELETE /properties/{propertyId}/guests/{guestId}/documents/{documentId} (ADMIN/MANAGER; booking OPEN or CHECKED_IN; deletes file + row)
- Document file endpoint accepts Firebase auth (ADMIN/MANAGER) or token query param.
- AI extraction is queued (single-thread) to limit concurrency.
- storage.documents.aiBaseUrl supported for llama fetch (defaults to publicBaseUrl; use http for llama).
Room images
- /properties/{propertyId}/rooms/{roomId}/images (upload/list)
@@ -165,7 +126,7 @@ Room images
- Thumbnails generated (320px).
Transport modes
- /properties/{propertyId}/transport-modes -> returns enabled list (property or default all).
- /properties/{propertyId}/transport-modes -> returns enabled list (property > org > default all).
Inbound email ingestion
- IMAP poller (1 min) with enable flag.
@@ -192,36 +153,5 @@ Config
Notes / constraints
- Users are created by app; API only manages roles.
- Super admin can create properties and assign users to properties.
- Admin can assign ADMIN/MANAGER/STAFF/AGENT; Manager can assign STAFF/AGENT.
- Agents can only see free rooms.
- Role hierarchy for visibility/management: SUPER_ADMIN > ADMIN > MANAGER > STAFF/HOUSEKEEPING/FINANCE/SUPERVISOR/GUIDE > AGENT. Users cannot see anyone above their rank in property user lists. Access code invites cannot assign ADMIN.
- Property code is auto-generated (7-char random, no fixed prefix). Property create no longer accepts `code` in request. Join-by-code uses property code, not propertyId.
- Property access codes: 6-digit PIN, 1-minute expiry, single-use. Admin generates; staff joins with property code + PIN.
- Property user disable is property-scoped (not global); hierarchy applies for who can disable.
- Room stay lifecycle: `RoomStay` now supports soft void (`is_voided`), and room-stay audit events are written to `room_stay_audit_log`.
- Checkout supports both booking-level and specific room-stay checkout; specific checkout endpoint: `POST /properties/{propertyId}/bookings/{bookingId}/room-stays/{roomStayId}/check-out`.
- Staff can change/void stays only before first payment on booking; manager/admin can act after payments.
- Checkout validation: nightly rate must be within +/-20% of room type default rate (when default rate exists), and minimum stay duration must be at least 1 hour.
- Room-type reservations: use booking room requests (`booking_room_request`) for quantity holds without room numbers; availability checks include active requests + occupied stays.
- Cancellation policy engine (advance bookings): policy per property with `freeDaysBeforeCheckin` + `penaltyMode` (`NO_CHARGE`, `ONE_NIGHT`, `FULL_STAY`). On cancel/no-show, penalty charge ledger rows are auto-created (`CANCELLATION_PENALTY` / `NO_SHOW_PENALTY`) when within penalty window.
Operational notes
- Payment provider migrated: PayU removed; Razorpay now used for settings, QR, payment links, and webhooks.
- Server access: SSH host alias `hotel` is available for server operations (e.g., `ssh hotel`). Use carefully; DB changes were done via `sudo -u postgres psql` on the server when needed.
- Schema changes: schema fix classes have been removed. If a new column/table is required, apply it manually on the server using `ssh hotel` and `sudo -u postgres psql -d trisolaris`, e.g. `alter table ... add column ...`. Keep a note of the exact SQL applied.
- Agent workflow expectation: when schema/runtime issues require server-side SQL or service checks, execute the required `ssh hotel` operations directly and report what was changed; do not block on asking for confirmation in normal flow.
Access / ops notes (prod)
- Service: `TrisolarisServer.service` (systemd). `systemctl cat TrisolarisServer.service`.
- Deploy path: `/opt/deploy/TrisolarisServer` (runs `build/libs/*.jar`).
- Active profile: `prod` (see service Environment=SPRING_PROFILES_ACTIVE=prod).
- DB (prod): PostgreSQL `trisolaris` on `localhost:5432` (see `/opt/deploy/TrisolarisServer/src/main/resources/application-prod.properties` on the server).
- DB (dev): PostgreSQL `trisolaris` on `192.168.1.53:5432` (see `application-dev.properties` in the repo).
- DB access (server): `sudo -u postgres psql -d trisolaris`.
- Workflow: Always run build, commit, and push for changes unless explicitly told otherwise.
- API docs policy (mandatory):
- For every API change (`add`, `update`, `delete`, path change, request/response change, role change, validation/error change), update `docs/API_REFERENCE.txt` in the same endpoint-by-endpoint text format already used there.
- Keep each API block in this style: `"<Name> API is this one:"` -> `METHOD /path` -> `What it does` -> `Request body` -> `Allowed roles` -> `Error Codes`.
- Script-generated API docs are forbidden. Documentation updates must be manual edits only.
- Android workflow note: user always runs Shift+F10 in Android Studio to deploy updates.

View File

@@ -12,7 +12,7 @@ description = "TrisolarisServer"
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
languageVersion = JavaLanguageVersion.of(19)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +0,0 @@
org.gradle.caching=true
org.gradle.configuration-cache=true
org.gradle.parallel=true
org.gradle.vfs.watch=true
org.gradle.daemon=false
org.gradle.jvmargs=-Xmx10g -Dfile.encoding=UTF-8
kotlin.incremental=true
systemProp.org.gradle.internal.http.connectionTimeout=10000
systemProp.org.gradle.internal.http.socketTimeout=10000

File diff suppressed because it is too large Load Diff

View File

@@ -1,19 +1 @@
pluginManagement {
repositories {
mavenCentral()
gradlePluginPortal()
}
resolutionStrategy {
eachPlugin {
when (requested.id.id) {
"org.springframework.boot" ->
useModule("org.springframework.boot:spring-boot-gradle-plugin:${requested.version}")
"io.spring.dependency-management" ->
useModule("io.spring.gradle:dependency-management-plugin:${requested.version}")
}
}
}
}
rootProject.name = "TrisolarisServer"

View File

@@ -3,13 +3,11 @@ package com.android.trisolarisserver
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.scheduling.annotation.EnableScheduling
import java.util.TimeZone
@SpringBootApplication
@EnableScheduling
class TrisolarisServerApplication
fun main(args: Array<String>) {
TimeZone.setDefault(TimeZone.getTimeZone("Asia/Kolkata"))
runApplication<TrisolarisServerApplication>(*args)
}

View File

@@ -1,4 +1,4 @@
package com.android.trisolarisserver.component.storage
package com.android.trisolarisserver.component
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component

View File

@@ -1,4 +1,4 @@
package com.android.trisolarisserver.component.document
package com.android.trisolarisserver.component
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component

View File

@@ -1,4 +1,4 @@
package com.android.trisolarisserver.component.storage
package com.android.trisolarisserver.component
import org.apache.pdfbox.pdmodel.PDDocument
import org.apache.pdfbox.pdmodel.PDPage
@@ -8,6 +8,7 @@ import org.apache.pdfbox.pdmodel.font.PDType1Font
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import java.time.OffsetDateTime
import java.util.UUID
@@ -111,4 +112,12 @@ class EmailStorage(
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

@@ -1,4 +1,4 @@
package com.android.trisolarisserver.component.ai
package com.android.trisolarisserver.component
import com.fasterxml.jackson.databind.ObjectMapper
import org.springframework.beans.factory.annotation.Value
@@ -13,34 +13,17 @@ class LlamaClient(
private val restTemplate: RestTemplate,
private val objectMapper: ObjectMapper,
@Value("\${ai.llama.baseUrl}")
private val baseUrl: String,
@Value("\${ai.llama.temperature:0.7}")
private val temperature: Double,
@Value("\${ai.llama.topP:0.8}")
private val topP: Double,
@Value("\${ai.llama.minP:0.2}")
private val minP: Double,
@Value("\${ai.llama.repeatPenalty:1.0}")
private val repeatPenalty: Double,
@Value("\${ai.llama.topK:40}")
private val topK: Int,
@Value("\${ai.llama.model}")
private val model: String
private val baseUrl: String
) {
private val systemPrompt =
"Read extremely carefully. Look only at visible text. " +
"Look only at visible text. " +
"Return the exact text you can read verbatim. " +
"If the text is unclear, partial, or inferred, return NOT CLEARLY VISIBLE. " +
"Do not guess. Do not explain."
fun ask(imageUrl: String, question: String): String {
val payload = mapOf(
"model" to model,
"temperature" to temperature,
"top_p" to topP,
"min_p" to minP,
"repeat_penalty" to repeatPenalty,
"top_k" to topK,
"model" to "qwen",
"messages" to listOf(
mapOf(
"role" to "system",
@@ -55,45 +38,18 @@ class LlamaClient(
)
)
)
return post(payload)
}
fun askWithOcr(imageUrl: String, ocrText: String, question: String): String {
val payload = mapOf(
"model" to model,
"temperature" to temperature,
"top_p" to topP,
"min_p" to minP,
"repeat_penalty" to repeatPenalty,
"top_k" to topK,
"messages" to listOf(
mapOf(
"role" to "system",
"content" to systemPrompt
),
mapOf(
"role" to "user",
"content" to listOf(
mapOf(
"type" to "text",
"text" to "${question}\n\nOCR:\n${ocrText}"
),
mapOf("type" to "image_url", "image_url" to mapOf("url" to imageUrl))
)
)
)
)
return post(payload)
val headers = HttpHeaders()
headers.contentType = MediaType.APPLICATION_JSON
val entity = HttpEntity(payload, headers)
val response = restTemplate.postForEntity(baseUrl, entity, String::class.java)
val body = response.body ?: return ""
val node = objectMapper.readTree(body)
return node.path("choices").path(0).path("message").path("content").asText()
}
fun askText(content: String, question: String): String {
val payload = mapOf(
"model" to model,
"temperature" to temperature,
"top_p" to topP,
"min_p" to minP,
"repeat_penalty" to repeatPenalty,
"top_k" to topK,
"model" to "qwen",
"messages" to listOf(
mapOf(
"role" to "system",
@@ -105,10 +61,6 @@ class LlamaClient(
)
)
)
return post(payload)
}
private fun post(payload: Map<String, Any>): String {
val headers = HttpHeaders()
headers.contentType = MediaType.APPLICATION_JSON
val entity = HttpEntity(payload, headers)

View File

@@ -0,0 +1,22 @@
package com.android.trisolarisserver.component
import com.android.trisolarisserver.repo.PropertyUserRepo
import com.android.trisolarisserver.models.property.Role
import org.springframework.security.access.AccessDeniedException
import org.springframework.stereotype.Component
import java.util.UUID
@Component
class PropertyAccess(
private val repo: PropertyUserRepo
) {
fun requireMember(propertyId: UUID, userId: UUID) {
if (!repo.existsByIdPropertyIdAndIdUserId(propertyId, userId))
throw AccessDeniedException("No access to property")
}
fun requireAnyRole(propertyId: UUID, userId: UUID, vararg roles: Role) {
if (!repo.hasAnyRole(propertyId, userId, roles.toSet()))
throw AccessDeniedException("Missing role")
}
}

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

@@ -1,4 +1,4 @@
package com.android.trisolarisserver.component.storage
package com.android.trisolarisserver.component
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
@@ -6,6 +6,7 @@ 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
@@ -16,18 +17,6 @@ class RoomImageStorage(
@Value("\${storage.rooms.root:/home/androidlover5842/docs/rooms}")
private val rootPath: String
) {
init {
val root = Paths.get(rootPath)
try {
Files.createDirectories(root)
} catch (ex: Exception) {
throw IllegalStateException("Room image root not writable: $rootPath", ex)
}
if (!Files.isWritable(root)) {
throw IllegalStateException("Room image root not writable: $rootPath")
}
}
fun store(propertyId: UUID, roomId: UUID, file: MultipartFile): StoredRoomImage {
val contentType = file.contentType ?: ""
if (!contentType.startsWith("image/")) {
@@ -38,11 +27,7 @@ class RoomImageStorage(
val ext = extensionFor(contentType, originalName)
val dir = Paths.get(rootPath, propertyId.toString(), roomId.toString())
try {
Files.createDirectories(dir)
} catch (ex: Exception) {
throw IllegalStateException("Failed to create room image directory: $dir", ex)
}
Files.createDirectories(dir)
val base = UUID.randomUUID().toString() + "_" + OffsetDateTime.now().toEpochSecond()
val originalPath = dir.resolve("$base.$ext")
val originalTmp = dir.resolve("$base.$ext.tmp")
@@ -101,6 +86,13 @@ class RoomImageStorage(
}
}
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(

View File

@@ -1,42 +0,0 @@
package com.android.trisolarisserver.component.ai
import com.android.trisolarisserver.component.ai.formatAadhaar
import com.android.trisolarisserver.component.ai.isValidAadhaar
internal fun formatAadhaar(value: String): String {
if (value.length != 12) return value
return value.chunked(4).joinToString(" ")
}
internal fun isValidAadhaar(value: String): Boolean {
if (value.length != 12 || !value.all { it.isDigit() }) return false
var c = 0
val reversed = value.reversed()
for (i in reversed.indices) {
val digit = reversed[i].digitToInt()
c = aadhaarMultiplication[c][aadhaarPermutation[i % 8][digit]]
}
return c == 0
}
private val aadhaarMultiplication = arrayOf(
intArrayOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9),
intArrayOf(1, 2, 3, 4, 0, 6, 7, 8, 9, 5),
intArrayOf(2, 3, 4, 0, 1, 7, 8, 9, 5, 6),
intArrayOf(3, 4, 0, 1, 2, 8, 9, 5, 6, 7),
intArrayOf(4, 0, 1, 2, 3, 9, 5, 6, 7, 8),
intArrayOf(5, 9, 8, 7, 6, 0, 4, 3, 2, 1),
intArrayOf(6, 5, 9, 8, 7, 1, 0, 4, 3, 2),
intArrayOf(7, 6, 5, 9, 8, 2, 1, 0, 4, 3),
intArrayOf(8, 7, 6, 5, 9, 3, 2, 1, 0, 4),
intArrayOf(9, 8, 7, 6, 5, 4, 3, 2, 1, 0)
)
private val aadhaarPermutation = arrayOf(
intArrayOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9),
intArrayOf(1, 5, 7, 6, 2, 8, 3, 0, 9, 4),
intArrayOf(5, 8, 0, 3, 7, 9, 6, 1, 4, 2),
intArrayOf(8, 9, 1, 6, 0, 4, 3, 5, 2, 7),
intArrayOf(9, 4, 5, 3, 1, 2, 6, 8, 7, 0),
intArrayOf(4, 2, 8, 6, 5, 7, 3, 9, 0, 1),
intArrayOf(2, 7, 9, 3, 8, 0, 6, 4, 1, 5),
intArrayOf(7, 0, 4, 6, 9, 1, 3, 2, 5, 8)
)

View File

@@ -1,27 +0,0 @@
package com.android.trisolarisserver.component.ai
import jakarta.annotation.PreDestroy
import org.springframework.stereotype.Component
import java.util.concurrent.Executors
@Component
class ExtractionQueue {
private val executor = Executors.newSingleThreadExecutor { runnable ->
Thread(runnable, "doc-extraction-queue").apply { isDaemon = true }
}
fun enqueue(task: () -> Unit) {
executor.submit {
try {
task()
} catch (_: Exception) {
// Best-effort processing; failures should not crash the worker.
}
}
}
@PreDestroy
fun shutdown() {
executor.shutdown()
}
}

View File

@@ -1,136 +0,0 @@
package com.android.trisolarisserver.component.ai
import com.android.trisolarisserver.component.ai.formatAadhaar
import com.android.trisolarisserver.component.ai.isValidAadhaar
import com.fasterxml.jackson.databind.ObjectMapper
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.core.io.FileSystemResource
import org.springframework.http.HttpEntity
import org.springframework.http.HttpHeaders
import org.springframework.http.MediaType
import org.springframework.stereotype.Component
import org.springframework.util.LinkedMultiValueMap
import org.springframework.web.client.RestTemplate
import java.nio.file.Files
import java.nio.file.Path
@Component
class PaddleOcrClient(
private val restTemplate: RestTemplate,
private val objectMapper: ObjectMapper,
@Value("\${ocr.paddle.enabled:false}")
private val enabled: Boolean,
@Value("\${ocr.paddle.baseUrl:https://ocr.hoteltrisolaris.in}")
private val baseUrl: String,
@Value("\${ocr.paddle.minScore:0.9}")
private val minScore: Double,
@Value("\${ocr.paddle.minAverageScore:0.8}")
private val minAverageScore: Double,
@Value("\${ocr.paddle.minTextLength:4}")
private val minTextLength: Int
) {
private val logger = LoggerFactory.getLogger(PaddleOcrClient::class.java)
private val aadhaarRegex = Regex("\\b(?:\\d[\\s-]?){12}\\b")
fun extract(filePath: String): PaddleOcrResult? {
if (!enabled) return null
val path = Path.of(filePath)
if (!Files.exists(path)) return null
return try {
val sizeBytes = Files.size(path)
logger.debug("PaddleOCR extract path={} sizeBytes={}", path, sizeBytes)
val output = callOcr(path)
val average = averageScore(output.scores)
val rawCandidates = extractCandidates(output.texts)
val filtered = filterByScore(output.texts, output.scores, minScore, minTextLength)
val filteredCandidates = extractCandidates(filtered)
val aadhaar = extractAadhaar(filtered)
if (rawCandidates.isNotEmpty() || filteredCandidates.isNotEmpty() || aadhaar != null) {
logger.debug(
"PaddleOCR candidates path={} raw={} filtered={} selected={}",
path,
rawCandidates.map { maskAadhaar(it) },
filteredCandidates.map { maskAadhaar(it) },
aadhaar?.let { maskAadhaar(it) }
)
}
val rejected = average != null && average < minAverageScore
PaddleOcrResult(filtered, aadhaar, average, rejected)
} catch (ex: Exception) {
logger.warn("PaddleOCR failed: {}", ex.message)
null
}
}
private fun callOcr(path: Path): OcrPayload {
val headers = HttpHeaders()
headers.contentType = MediaType.MULTIPART_FORM_DATA
val body = LinkedMultiValueMap<String, Any>().apply {
add("file", FileSystemResource(path.toFile()))
}
val entity = HttpEntity(body, headers)
val response = restTemplate.postForEntity(baseUrl, entity, String::class.java)
val raw = response.body ?: return OcrPayload(emptyList(), emptyList())
val node = objectMapper.readTree(raw)
val texts = node.path("texts")
val scores = node.path("scores")
if (!texts.isArray) return OcrPayload(emptyList(), emptyList())
val parsedTexts = texts.mapNotNull { it.asText(null) }
val parsedScores = if (scores.isArray) {
scores.mapNotNull { if (it.isNumber) it.asDouble() else null }
} else {
emptyList()
}
return OcrPayload(parsedTexts, parsedScores)
}
private fun filterByScore(texts: List<String>, scores: List<Double>, min: Double, minLen: Int): List<String> {
if (scores.size != texts.size || scores.isEmpty()) return texts
return texts.mapIndexedNotNull { index, text ->
if (scores[index] >= min && text.trim().length >= minLen) text else null
}
}
private fun averageScore(scores: List<Double>): Double? {
if (scores.isEmpty()) return null
return scores.sum() / scores.size
}
private fun extractAadhaar(texts: List<String>): String? {
val candidates = extractCandidates(texts)
val valid = candidates.firstOrNull { isValidAadhaar(it) } ?: return null
return formatAadhaar(valid)
}
private fun extractCandidates(texts: List<String>): List<String> {
val joined = texts.joinToString(" ")
val candidates = mutableListOf<String>()
aadhaarRegex.findAll(joined).forEach { match ->
val digits = match.value.filter { it.isDigit() }
if (digits.length == 12) {
candidates.add(digits)
}
}
return candidates
}
private fun maskAadhaar(value: String): String {
val digits = value.filter { it.isDigit() }
if (digits.length != 12) return value
return "XXXXXXXX" + digits.takeLast(4)
}
}
data class PaddleOcrResult(
val texts: List<String>,
val aadhaar: String?,
val averageScore: Double?,
val rejected: Boolean
)
private data class OcrPayload(
val texts: List<String>,
val scores: List<Double>
)

View File

@@ -1,39 +0,0 @@
package com.android.trisolarisserver.component.auth
import com.android.trisolarisserver.controller.common.requireMember
import com.android.trisolarisserver.repo.property.AppUserRepo
import com.android.trisolarisserver.repo.property.PropertyUserRepo
import com.android.trisolarisserver.models.property.Role
import org.springframework.security.access.AccessDeniedException
import org.springframework.stereotype.Component
import java.util.UUID
@Component
class PropertyAccess(
private val repo: PropertyUserRepo,
private val appUserRepo: AppUserRepo
) {
fun requireMember(propertyId: UUID, userId: UUID) {
val user = appUserRepo.findById(userId).orElse(null)
if (user == null) {
throw AccessDeniedException("No access to property (user not found)")
}
if (user.superAdmin) {
return
}
if (!repo.existsByIdPropertyIdAndIdUserId(propertyId, userId)) {
throw AccessDeniedException("No access to property (not a member)")
}
}
fun requireAnyRole(propertyId: UUID, userId: UUID, vararg roles: Role) {
val user = appUserRepo.findById(userId).orElse(null)
if (user == null) {
throw AccessDeniedException("Missing role (user not found)")
}
if (user.superAdmin) return
if (!repo.hasAnyRole(propertyId, userId, roles.toSet())) {
throw AccessDeniedException("Missing role (no matching roles)")
}
}
}

View File

@@ -1,36 +0,0 @@
package com.android.trisolarisserver.component.booking
import com.android.trisolarisserver.component.sse.SseHub
import com.android.trisolarisserver.controller.booking.BookingSnapshotBuilder
import com.android.trisolarisserver.controller.dto.booking.BookingDetailResponse
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Component
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter
import java.util.UUID
@Component
class BookingEvents(
private val bookingSnapshotBuilder: BookingSnapshotBuilder
) {
private val hub = SseHub<BookingKey>("booking") { key ->
bookingSnapshotBuilder.build(key.propertyId, key.bookingId)
}
fun subscribe(propertyId: UUID, bookingId: UUID): SseEmitter {
return hub.subscribe(BookingKey(propertyId, bookingId))
}
fun emit(propertyId: UUID, bookingId: UUID) {
hub.emit(BookingKey(propertyId, bookingId))
}
@Scheduled(fixedDelayString = "25000")
fun heartbeat() {
hub.heartbeat()
}
}
private data class BookingKey(
val propertyId: UUID,
val bookingId: UUID
)

View File

@@ -1,834 +0,0 @@
package com.android.trisolarisserver.component.document
import com.android.trisolarisserver.component.ai.LlamaClient
import com.android.trisolarisserver.component.ai.PaddleOcrClient
import com.android.trisolarisserver.component.ai.PaddleOcrResult
import com.android.trisolarisserver.component.ai.formatAadhaar
import com.android.trisolarisserver.component.ai.isValidAadhaar
import com.android.trisolarisserver.component.booking.BookingEvents
import com.android.trisolarisserver.component.geo.PincodeResolver
import com.android.trisolarisserver.controller.document.DocumentPrompts
import com.android.trisolarisserver.repo.guest.GuestRepo
import com.android.trisolarisserver.repo.guest.GuestDocumentRepo
import com.android.trisolarisserver.repo.booking.BookingRepo
import com.android.trisolarisserver.models.booking.GuestDocument
import com.android.trisolarisserver.models.booking.GuestVehicle
import com.android.trisolarisserver.repo.guest.GuestVehicleRepo
import com.android.trisolarisserver.repo.property.PropertyRepo
import com.fasterxml.jackson.core.type.TypeReference
import com.fasterxml.jackson.databind.ObjectMapper
import java.time.OffsetDateTime
import java.time.LocalDate
import java.time.Period
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeFormatterBuilder
import java.time.format.ResolverStyle
import java.util.UUID
import org.slf4j.LoggerFactory
@org.springframework.stereotype.Component
class DocumentExtractionService(
private val llamaClient: LlamaClient,
private val guestRepo: GuestRepo,
private val guestDocumentRepo: GuestDocumentRepo,
private val guestVehicleRepo: GuestVehicleRepo,
private val propertyRepo: PropertyRepo,
private val paddleOcrClient: PaddleOcrClient,
private val bookingRepo: BookingRepo,
private val pincodeResolver: PincodeResolver,
private val bookingEvents: BookingEvents,
private val objectMapper: ObjectMapper
) {
private val logger = LoggerFactory.getLogger(DocumentExtractionService::class.java)
private val aadhaarRegex = Regex("\\b\\d{4}\\s?\\d{4}\\s?\\d{4}\\b")
fun extractAndApply(localImageUrl: String, publicImageUrl: String, document: GuestDocument, propertyId: UUID): ExtractionResult {
val results = linkedMapOf<String, String>()
val ocrResult = paddleOcrClient.extract(document.storagePath)
if (ocrResult?.texts?.isNotEmpty() == true) {
val preview = ocrResult.texts.take(30).joinToString(" | ") { it.take(80) }
logger.debug("OCR texts preview docId={}: {}", document.id, preview)
}
if (ocrResult?.rejected == true) {
results["docType"] = "REJECTED"
results["rejectReason"] = "LOW_OCR_SCORE"
results["ocrAverage"] = ocrResult.averageScore?.toString() ?: "UNKNOWN"
return ExtractionResult(results, false)
}
val ocrText = ocrResult?.texts?.takeIf { it.isNotEmpty() }?.joinToString("\n")
if (!ocrText.isNullOrBlank()) {
val candidates = aadhaarRegex.findAll(ocrText).map { it.value }.toList()
if (candidates.isNotEmpty()) {
val normalized = candidates.map { it.replace(Regex("\\s+"), "") }
val valid = normalized.filter { isValidAadhaar(it) }.map { maskAadhaar(it) }
logger.debug(
"OCR Aadhaar candidates docId={} candidates={} valid={}",
document.id,
normalized.map { maskAadhaar(it) },
valid
)
}
}
val detections = listOf(
Detection(
detect = {
results["isVehiclePhoto"] = askWithContext(
ocrText,
localImageUrl,
"IS THIS A VEHICLE NUMBER PLATE PHOTO? Answer YES or NO only."
)
if (!isYes(results["isVehiclePhoto"])) return@Detection false
val candidate = askWithContext(
ocrText,
localImageUrl,
"VEHICLE NUMBER PLATE? Reply only number or NONE."
)
val cleaned = cleanedValue(candidate)
if (cleaned != null && isLikelyVehicleNumber(cleaned)) {
results["vehicleNumber"] = cleaned
true
} else {
results["vehicleNumber"] = "NONE"
results["isVehiclePhoto"] = "NO"
false
}
},
handle = {}
),
Detection(
detect = {
results["hasAadhar"] = askWithContext(
ocrText,
localImageUrl,
"CONTAINS AADHAAR? Answer YES or NO only."
)
val hasUidai = isYes(askWithContext(ocrText,localImageUrl,"CONTAINS UIDAI? Answer YES or NO only.")) ||
isYes(askWithContext(ocrText,localImageUrl,"CONTAINS Unique Identification Authority of India? Answer YES or NO only."))
isYes(results["hasAadhar"]) || hasUidai
},
handle = {
val aadharQuestions = linkedMapOf(
"hasAddress" to "POSTAL ADDRESS PRESENT? Answer YES or NO only.",
"hasDob" to "DOB? Reply YES or NO.",
"hasGenderMentioned" to "GENDER MENTIONED? Reply YES or NO."
)
for ((key, question) in aadharQuestions) {
results[key] = askWithContext(ocrText, localImageUrl, question)
}
val hasAddress = isYes(results["hasAddress"])
if (hasAddress) {
val addressQuestions = linkedMapOf(
DocumentPrompts.PIN_CODE,
DocumentPrompts.ADDRESS,
DocumentPrompts.ID_NUMBER)
for ((key, question) in addressQuestions) {
results[key] = askWithContext(ocrText, localImageUrl, question)
}
}
val hasDob = isYes(results["hasDob"])
val hasGender = isYes(results["hasGenderMentioned"])
if (hasDob && hasGender) {
val aadharFrontQuestions = linkedMapOf(
DocumentPrompts.NAME,
DocumentPrompts.DOB,
DocumentPrompts.ID_NUMBER,
DocumentPrompts.GENDER
)
for ((key, question) in aadharFrontQuestions) {
results[key] = askWithContext(ocrText, localImageUrl, question)
}
ensureAadhaarId(localImageUrl, publicImageUrl, document, results, ocrResult, ocrText)
}
}
),
Detection(
detect = {
results["hasDrivingLicence"] = askWithContext(
ocrText,
localImageUrl,
"CONTAINS DRIVING LICENCE? Answer YES or NO only."
)
results["hasTransportDept"] = askWithContext(
ocrText,
localImageUrl,
"CONTAINS TRANSPORT DEPARTMENT? Answer YES or NO only."
)
isYes(results["hasDrivingLicence"]) || isYes(results["hasTransportDept"])
},
handle = {
val drivingQuestions = linkedMapOf(
DocumentPrompts.NAME,
DocumentPrompts.DOB,
"idNumber" to "DL NUMBER? Reply only number or NONE.",
DocumentPrompts.ADDRESS,
DocumentPrompts.PIN_CODE,
DocumentPrompts.CITY,
DocumentPrompts.GENDER,
DocumentPrompts.NATIONALITY
)
for ((key, question) in drivingQuestions) {
results[key] = askWithContext(ocrText, localImageUrl, question)
}
}
),
Detection(
detect = {
results["hasElectionCommission"] = askWithContext(
ocrText,
localImageUrl,
"CONTAINS ELECTION COMMISSION OF INDIA? Answer YES or NO only."
)
isYes(results["hasElectionCommission"])
},
handle = {
val voterQuestions = linkedMapOf(
DocumentPrompts.NAME,
DocumentPrompts.DOB,
"idNumber" to "VOTER ID NUMBER? Reply only number or NONE.",
DocumentPrompts.ADDRESS,
DocumentPrompts.PIN_CODE,
DocumentPrompts.CITY,
DocumentPrompts.GENDER,
DocumentPrompts.NATIONALITY
)
for ((key, question) in voterQuestions) {
results[key] = askWithContext(ocrText, localImageUrl, question)
}
}
),
Detection(
detect = {
results["hasIncomeTaxDept"] = askWithContext(
ocrText,
localImageUrl,
"CONTAINS INCOME TAX DEPARTMENT? Answer YES or NO only."
)
isYes(results["hasIncomeTaxDept"])
},
handle = {
val panQuestions = linkedMapOf(
DocumentPrompts.NAME,
DocumentPrompts.DOB,
"idNumber" to "PAN NUMBER? Reply only number or NONE.",
DocumentPrompts.ADDRESS,
DocumentPrompts.PIN_CODE,
DocumentPrompts.CITY,
DocumentPrompts.GENDER,
DocumentPrompts.NATIONALITY
)
for ((key, question) in panQuestions) {
results[key] = askWithContext(ocrText, localImageUrl, question)
}
}
),
Detection(
detect = {
results["hasPassport"] = askWithContext(
ocrText,
localImageUrl,
"CONTAINS PASSPORT? Answer YES or NO only."
)
isYes(results["hasPassport"])
},
handle = {
val passportQuestions = linkedMapOf(
DocumentPrompts.NAME,
DocumentPrompts.DOB,
"idNumber" to "PASSPORT NUMBER? Reply only number or NONE.",
DocumentPrompts.ADDRESS,
DocumentPrompts.PIN_CODE,
DocumentPrompts.CITY,
DocumentPrompts.GENDER,
DocumentPrompts.NATIONALITY
)
for ((key, question) in passportQuestions) {
results[key] = askWithContext(ocrText, localImageUrl, question)
}
}
)
)
var handled = false
for (detection in detections) {
if (detection.detect()) {
detection.handle()
handled = true
break
}
}
if (!handled) {
val generalQuestions = linkedMapOf(
DocumentPrompts.NAME,
DocumentPrompts.DOB,
DocumentPrompts.ID_NUMBER,
DocumentPrompts.ADDRESS,
DocumentPrompts.VEHICLE_NUMBER,
DocumentPrompts.PIN_CODE,
DocumentPrompts.CITY,
DocumentPrompts.GENDER,
DocumentPrompts.NATIONALITY
)
for ((key, question) in generalQuestions) {
results[key] = askWithContext(ocrText, localImageUrl, question)
}
}
normalizePinCode(results)
logIdNumber("before-normalize-id", document.id, results)
normalizeIdNumber(results)
logIdNumber("after-normalize-id-digits", document.id, results)
normalizeAddress(results)
computeAgeIfDobPresent(results, propertyId)
applyBookingCityUpdates(document, results)
// Final Aadhaar checksum pass before doc type decision.
markAadhaarIfValid(results)
applyAadhaarVerificationAndMatching(document, results)
logIdNumber("after-aadhaar-checksum", document.id, results)
results["docType"] = computeDocType(results, handled)
applyGuestUpdates(document, propertyId, results)
return ExtractionResult(results, handled)
}
private fun isYes(value: String?): Boolean {
return value.orEmpty().contains("YES", ignoreCase = true)
}
private fun isLikelyVehicleNumber(value: String): Boolean {
val normalized = value.uppercase().replace(Regex("[\\s-]"), "")
if (normalized.length == 12 && normalized.all { it.isDigit() }) return false
if (normalized.length < 6) return false
return standardPlateRegex.matches(normalized) || bhPlateRegex.matches(normalized)
}
private fun normalizePinCode(results: MutableMap<String, String>) {
val pinKey = DocumentPrompts.PIN_CODE.first
val rawPin = cleanedValue(results[pinKey])
val address = cleanedValue(results[DocumentPrompts.ADDRESS.first])
val fromPin = extractPinFromValue(rawPin)
val fromAddress = extractPinFromAddress(address)
val chosen = fromPin ?: fromAddress
results[pinKey] = if (isValidPin(chosen)) chosen!! else "NONE"
}
private fun normalizeIdNumber(results: MutableMap<String, String>) {
val idKey = DocumentPrompts.ID_NUMBER.first
val raw = cleanedValue(results[idKey])
val digits = normalizeDigits(raw)
if (digits != null && isValidAadhaar(digits)) {
results[idKey] = digits
}
}
private fun normalizeAddress(results: MutableMap<String, String>) {
val key = DocumentPrompts.ADDRESS.first
val raw = cleanedValue(results[key]) ?: return
val normalized = cleanAddress(raw) ?: return
results[key] = normalized
}
private fun computeAgeIfDobPresent(results: MutableMap<String, String>, propertyId: UUID) {
val dobRaw = cleanedValue(results[DocumentPrompts.DOB.first]) ?: return
val dob = parseDob(dobRaw) ?: return
val property = propertyRepo.findById(propertyId).orElse(null) ?: return
val zone = runCatching { ZoneId.of(property.timezone) }.getOrNull() ?: ZoneId.systemDefault()
val today = LocalDate.now(zone)
val years = Period.between(dob, today).years
if (years in 0..120) {
results["age"] = years.toString()
}
}
private fun markAadhaarIfValid(results: MutableMap<String, String>) {
val idKey = DocumentPrompts.ID_NUMBER.first
val digits = normalizeDigits(cleanedValue(results[idKey]))
if (digits != null && isValidAadhaar(digits)) {
results["hasAadhar"] = "YES"
}
}
private fun applyAadhaarVerificationAndMatching(document: GuestDocument, results: MutableMap<String, String>) {
val bookingId = document.booking?.id ?: return
val idKey = DocumentPrompts.ID_NUMBER.first
val digits = normalizeDigits(cleanedValue(results[idKey]))
val hasDigits = digits != null && digits.length == 12
val isValid = hasDigits && isValidAadhaar(digits!!)
if (hasDigits) {
results["aadhaarVerified"] = if (isValid) "YES" else "NO"
}
val docs = guestDocumentRepo.findByBookingIdOrderByUploadedAtDesc(bookingId)
val verified = if (isValid) {
VerifiedAadhaar(document.id, digits!!)
} else {
docs.firstNotNullOfOrNull { existing ->
extractVerified(existing)
}
}
if (verified == null) return
if (!isValid && hasDigits) {
val match = computeAadhaarMatch(digits!!, verified.digits)
applyMatchResults(results, match, verified)
}
for (existing in docs) {
if (existing.id == document.id) continue
val existingDigits = extractAadhaarDigits(existing) ?: continue
if (isValidAadhaar(existingDigits)) continue
val match = computeAadhaarMatch(existingDigits, verified.digits)
val updated = updateExtractedData(existing, match, verified)
if (updated) {
guestDocumentRepo.save(existing)
}
}
}
private fun extractVerified(document: GuestDocument): VerifiedAadhaar? {
val digits = extractAadhaarDigits(document) ?: return null
if (!isValidAadhaar(digits)) return null
return VerifiedAadhaar(document.id, digits)
}
private fun extractAadhaarDigits(document: GuestDocument): String? {
val raw = extractFromDocument(document, DocumentPrompts.ID_NUMBER.first) ?: return null
val digits = normalizeDigits(cleanedValue(raw)) ?: return null
return if (digits.length == 12) digits else null
}
private fun extractFromDocument(document: GuestDocument, key: String): String? {
val data = document.extractedData ?: return null
return try {
val parsed: Map<String, String> = objectMapper.readValue(
data,
object : TypeReference<Map<String, String>>() {}
)
parsed[key]
} catch (_: Exception) {
null
}
}
private fun updateExtractedData(
document: GuestDocument,
match: AadhaarMatch,
verified: VerifiedAadhaar
): Boolean {
val raw = document.extractedData ?: return false
val parsed = try {
objectMapper.readValue(raw, object : TypeReference<MutableMap<String, String>>() {})
} catch (_: Exception) {
mutableMapOf()
}
val changed = applyMatchResults(parsed, match, verified)
if (!changed) return false
document.extractedData = objectMapper.writeValueAsString(parsed)
return true
}
private fun applyMatchResults(
results: MutableMap<String, String>,
match: AadhaarMatch,
verified: VerifiedAadhaar
): Boolean {
var changed = false
val targetMasked = maskAadhaar(verified.digits)
changed = setIfChanged(results, "aadhaarMatchOrdered", match.ordered.toString()) || changed
changed = setIfChanged(results, "aadhaarMatchUnordered", match.unordered.toString()) || changed
changed = setIfChanged(results, "aadhaarMatchSimilar", if (match.similar) "YES" else "NO") || changed
changed = setIfChanged(results, "aadhaarMatchWith", targetMasked) || changed
verified.id?.let {
changed = setIfChanged(results, "aadhaarMatchWithDocId", it.toString()) || changed
}
if (match.similar) {
val formatted = formatAadhaar(verified.digits)
changed = setIfChanged(results, DocumentPrompts.ID_NUMBER.first, formatted) || changed
changed = setIfChanged(results, "aadhaarVerified", "YES") || changed
changed = setIfChanged(results, "hasAadhar", "YES") || changed
val recomputed = computeDocType(results, true)
changed = setIfChanged(results, "docType", recomputed) || changed
}
return changed
}
private fun setIfChanged(results: MutableMap<String, String>, key: String, value: String): Boolean {
val current = results[key]
if (current == value) return false
results[key] = value
return true
}
private fun computeDocType(results: Map<String, String>, handled: Boolean): String {
if (!handled && !(isYes(results["hasAadhar"]) || isYes(results["hasUidai"]))) {
return "GENERAL"
}
return when {
isYes(results["hasCourt"]) ||
isYes(results["hasHighCourt"]) ||
isYes(results["hasSupremeCourt"]) ||
isYes(results["hasJudiciary"]) -> "COURT_ID"
isYes(results["hasPolice"]) -> "POLICE_ID"
isYes(results["hasPassport"]) -> "PASSPORT"
isYes(results["hasTransportDept"]) ||
isYes(results["hasDrivingLicence"]) -> "TRANSPORT"
isYes(results["hasIncomeTaxDept"]) -> "PAN"
isYes(results["hasElectionCommission"]) -> "VOTER_ID"
isYes(results["hasAadhar"]) ||
isYes(results["hasUidai"]) -> {
if (isYes(results["hasAddress"])) "AADHAR_BACK" else "AADHAR_FRONT"
}
results["vehicleNumber"].orEmpty().isNotBlank() &&
!results["vehicleNumber"]!!.contains("NONE", true) -> "VEHICLE"
isYes(results["isVehiclePhoto"]) -> "VEHICLE_PHOTO"
else -> "UNKNOWN"
}
}
private fun ensureAadhaarId(
localImageUrl: String,
publicImageUrl: String,
document: GuestDocument,
results: MutableMap<String, String>,
ocrResult: PaddleOcrResult?,
ocrText: String?
) {
val key = DocumentPrompts.ID_NUMBER.first
val current = cleanedValue(results[key])
val normalized = normalizeDigits(current)
if (normalized != null && isValidAadhaar(normalized)) {
results[key] = normalized
return
}
val retry = askWithContext(
ocrText,
localImageUrl,
"AADHAAR NUMBER (12 digits). Read extremely carefully. Reply ONLY the 12 digits or NONE."
)
val retryNormalized = normalizeDigits(cleanedValue(retry))
if (retryNormalized != null && isValidAadhaar(retryNormalized)) {
results[key] = retryNormalized
return
}
if (ocrResult != null) {
val ocrCandidate = ocrResult.aadhaar
if (ocrCandidate != null) {
val ocrDigits = ocrCandidate.replace(" ", "")
if (isValidAadhaar(ocrDigits)) {
results[key] = ocrDigits
return
}
}
if (ocrResult.texts.isNotEmpty()) {
val ocrText = ocrResult.texts.joinToString("\n")
val ocrAsk = askWithContext(
ocrText,
localImageUrl,
"AADHAAR NUMBER (12 digits). Reply ONLY the 12 digits or NONE."
)
val ocrAskNormalized = normalizeDigits(cleanedValue(ocrAsk))
if (ocrAskNormalized != null && isValidAadhaar(ocrAskNormalized)) {
results[key] = ocrAskNormalized
return
}
}
}
logger.warn("Aadhaar retry failed; setting idNumber=NONE")
results[key] = "NONE"
}
private fun askWithContext(ocrText: String?, imageUrl: String, question: String): String {
return if (ocrText != null) {
llamaClient.askWithOcr(imageUrl, ocrText, question)
} else {
llamaClient.ask(imageUrl, question)
}
}
private fun applyGuestUpdates(
document: GuestDocument,
propertyId: UUID,
results: Map<String, String>
) {
val extractedName = cleanedValue(results[DocumentPrompts.NAME.first])
val extractedAddress = cleanedValue(results[DocumentPrompts.ADDRESS.first])
val extractedDob = cleanedValue(results[DocumentPrompts.DOB.first])
val resolvedCountry = if (results["geoResolved"] != null) "India" else null
val guestIdValue = document.guest.id
if (guestIdValue != null && (extractedName != null || extractedAddress != null)) {
val guestEntity = guestRepo.findById(guestIdValue).orElse(null)
if (guestEntity != null) {
var updated = false
if (guestEntity.name.isNullOrBlank() && extractedName != null) {
guestEntity.name = extractedName
updated = true
}
if (guestEntity.addressText.isNullOrBlank() && extractedAddress != null) {
guestEntity.addressText = extractedAddress
updated = true
}
if (guestEntity.age.isNullOrBlank() && extractedDob != null) {
val dob = parseDob(extractedDob)
if (dob != null) {
guestEntity.age = dob.format(DateTimeFormatter.ofPattern("dd/MM/yyyy"))
updated = true
}
}
if (guestEntity.nationality.isNullOrBlank() && resolvedCountry != null) {
guestEntity.nationality = resolvedCountry
updated = true
}
if (updated) {
guestEntity.updatedAt = OffsetDateTime.now()
guestRepo.save(guestEntity)
}
}
}
val extractedVehicle = cleanedValue(results["vehicleNumber"])
if (isYes(results["isVehiclePhoto"]) && extractedVehicle != null) {
val guestIdSafe = document.guest.id
if (guestIdSafe != null &&
!guestVehicleRepo.existsByPropertyIdAndVehicleNumberIgnoreCase(propertyId, extractedVehicle)
) {
val property = propertyRepo.findById(propertyId).orElse(null)
val guestEntity = guestRepo.findById(guestIdSafe).orElse(null)
if (property != null && guestEntity != null) {
guestVehicleRepo.save(
GuestVehicle(
property = property,
guest = guestEntity,
booking = document.booking,
vehicleNumber = extractedVehicle
)
)
}
}
}
}
private fun applyBookingCityUpdates(document: GuestDocument, results: MutableMap<String, String>) {
val bookingId = document.booking?.id ?: return
val booking = bookingRepo.findById(bookingId).orElse(null) ?: return
if (booking.fromCity?.isNotBlank() == true && booking.toCity?.isNotBlank() == true) return
val pin = cleanedValue(results[DocumentPrompts.PIN_CODE.first]) ?: return
if (!isValidPin(pin)) return
val resolvedResult = pincodeResolver.resolve(pin)
val primary = resolvedResult.primary
results["geoPrimarySource"] = primary.source
primary.status?.let { results["geoPrimaryStatus"] = it }
primary.rawResponse?.let { results["geoPrimaryResponse"] = it.take(4000) }
primary.errorMessage?.let { results["geoPrimaryError"] = it.take(300) }
primary.requestUrl?.let { results["geoPrimaryUrl"] = it.take(500) }
resolvedResult.secondary?.let { secondary ->
results["geoSecondarySource"] = secondary.source
secondary.status?.let { results["geoSecondaryStatus"] = it }
secondary.rawResponse?.let { results["geoSecondaryResponse"] = it.take(4000) }
secondary.errorMessage?.let { results["geoSecondaryError"] = it.take(300) }
secondary.requestUrl?.let { results["geoSecondaryUrl"] = it.take(500) }
}
resolvedResult.tertiary?.let { tertiary ->
results["geoTertiarySource"] = tertiary.source
tertiary.status?.let { results["geoTertiaryStatus"] = it }
tertiary.rawResponse?.let { results["geoTertiaryResponse"] = it.take(4000) }
tertiary.errorMessage?.let { results["geoTertiaryError"] = it.take(300) }
tertiary.requestUrl?.let { results["geoTertiaryUrl"] = it.take(500) }
}
resolvedResult.quaternary?.let { quaternary ->
results["geoQuaternarySource"] = quaternary.source
quaternary.status?.let { results["geoQuaternaryStatus"] = it }
quaternary.rawResponse?.let { results["geoQuaternaryResponse"] = it.take(4000) }
quaternary.errorMessage?.let { results["geoQuaternaryError"] = it.take(300) }
quaternary.requestUrl?.let { results["geoQuaternaryUrl"] = it.take(500) }
}
val resolved = resolvedResult.resolved()?.resolvedCityState ?: return
results["geoResolved"] = resolved
results["geoSource"] = resolvedResult.resolved()?.source ?: ""
var updated = false
if (booking.fromCity.isNullOrBlank()) {
booking.fromCity = resolved
updated = true
}
if (booking.toCity.isNullOrBlank()) {
booking.toCity = resolved
updated = true
}
if (updated) {
booking.updatedAt = OffsetDateTime.now()
bookingRepo.save(booking)
val propertyId = booking.property.id ?: return
val bookingId = booking.id ?: return
bookingEvents.emit(propertyId, bookingId)
}
}
}
data class ExtractionResult(
val results: LinkedHashMap<String, String>,
val handled: Boolean
)
private data class Detection(
val detect: () -> Boolean,
val handle: () -> Unit
)
private fun cleanedValue(value: String?): String? {
val trimmed = value?.trim().orEmpty()
if (trimmed.isBlank()) return null
val upper = trimmed.uppercase()
if (upper == "NONE" || upper == "N/A" || upper == "NA" || upper == "NULL") return null
return trimmed
}
private fun maskAadhaar(value: String): String {
val digits = value.filter { it.isDigit() }
if (digits.length != 12) return value
return "XXXXXXXX" + digits.takeLast(4)
}
private fun logIdNumber(stage: String, documentId: UUID?, results: Map<String, String>) {
val raw = results[DocumentPrompts.ID_NUMBER.first]
val digits = normalizeDigits(cleanedValue(raw))
val masked = digits?.let { maskAadhaar(it) } ?: raw
LoggerFactory.getLogger(DocumentExtractionService::class.java).debug(
"ID number {} docId={} raw={} normalized={}",
stage,
documentId,
raw,
masked
)
}
private val standardPlateRegex = Regex("^[A-Z]{2}\\d{1,2}[A-Z]{1,3}\\d{3,4}$")
private val bhPlateRegex = Regex("^\\d{2}BH\\d{4}[A-Z]{1,2}$")
private val pinCodeRegex = Regex("\\b\\d{6}\\b")
private data class AadhaarMatch(
val ordered: Int,
val unordered: Int,
val similar: Boolean
)
private data class VerifiedAadhaar(
val id: UUID?,
val digits: String
)
private fun computeAadhaarMatch(candidate: String, verified: String): AadhaarMatch {
val ordered = candidate.zip(verified).count { it.first == it.second }
val unordered = unorderedMatchCount(candidate, verified)
val similar = ordered >= 8 || unordered >= 8
return AadhaarMatch(ordered, unordered, similar)
}
private fun unorderedMatchCount(a: String, b: String): Int {
val countsA = IntArray(10)
val countsB = IntArray(10)
a.forEach { if (it.isDigit()) countsA[it - '0']++ }
b.forEach { if (it.isDigit()) countsB[it - '0']++ }
var total = 0
for (i in 0..9) {
total += minOf(countsA[i], countsB[i])
}
return total
}
private fun parseDob(value: String): LocalDate? {
val cleaned = value.trim().replace(Regex("\\s+"), "")
val formatters = listOf(
DateTimeFormatterBuilder().parseCaseInsensitive().appendPattern("dd/MM/uuuu").toFormatter(),
DateTimeFormatterBuilder().parseCaseInsensitive().appendPattern("dd-MM-uuuu").toFormatter(),
DateTimeFormatterBuilder().parseCaseInsensitive().appendPattern("uuuu-MM-dd").toFormatter(),
DateTimeFormatterBuilder().parseCaseInsensitive().appendPattern("uuuu/MM/dd").toFormatter()
).map { it.withResolverStyle(ResolverStyle.STRICT) }
for (formatter in formatters) {
try {
return LocalDate.parse(cleaned, formatter)
} catch (_: Exception) {
}
}
return null
}
private fun extractPinFromValue(value: String?): String? {
if (value.isNullOrBlank()) return null
val compact = value.replace(Regex("\\s+"), "")
if (compact.length == 12 && compact.all { it.isDigit() }) return null
val match = pinCodeRegex.find(value) ?: return null
return match.value
}
private fun extractPinFromAddress(value: String?): String? {
if (value.isNullOrBlank()) return null
val hasPinLabel = value.contains("PIN", ignoreCase = true) || value.contains("PINCODE", ignoreCase = true)
if (!hasPinLabel) return null
val match = pinCodeRegex.find(value) ?: return null
return match.value
}
private fun normalizeDigits(value: String?): String? {
if (value.isNullOrBlank()) return null
val digits = value.filter { it.isDigit() }
return digits.ifBlank { null }
}
private fun isValidPin(value: String?): Boolean {
if (value.isNullOrBlank()) return false
return pinCodeRegex.matches(value)
}
private fun cleanAddress(raw: String): String? {
val relationRegex = Regex("^\\s*(S/O|D/O|W/O|C/O|H/O|F/O)\\b", RegexOption.IGNORE_CASE)
val prefixRegexes = listOf(
Regex("^\\s*ADDRESS\\b[:\\-\\s]*", RegexOption.IGNORE_CASE),
Regex("^\\s*HOUSE\\s*/\\s*BLDG\\.?\\s*/\\s*APT\\.?\\b[:\\-\\s]*", RegexOption.IGNORE_CASE),
Regex("^\\s*HOUSE-?BLDG\\b[:\\-\\s]*", RegexOption.IGNORE_CASE),
Regex("^\\s*HOUSE\\b[:\\-\\s]*", RegexOption.IGNORE_CASE),
Regex("^\\s*H\\s*NO\\b[:\\-\\s]*", RegexOption.IGNORE_CASE),
Regex("^\\s*HOUSE\\s*NO\\b[:\\-\\s]*", RegexOption.IGNORE_CASE),
Regex("^\\s*STREET/ROAD/LANE\\b[:\\-\\s]*", RegexOption.IGNORE_CASE),
Regex("^\\s*STREET\\b[:\\-\\s]*", RegexOption.IGNORE_CASE),
Regex("^\\s*ROAD\\b[:\\-\\s]*", RegexOption.IGNORE_CASE),
Regex("^\\s*LANE\\b[:\\-\\s]*", RegexOption.IGNORE_CASE),
Regex("^\\s*AREA\\b[:\\-\\s]*", RegexOption.IGNORE_CASE),
Regex("^\\s*SECTOR\\b[:\\-\\s]*", RegexOption.IGNORE_CASE),
Regex("^\\s*COLONY\\b[:\\-\\s]*", RegexOption.IGNORE_CASE),
Regex("^\\s*VILLAGE/TOWN/CITY\\b[:\\-\\s]*", RegexOption.IGNORE_CASE),
Regex("^\\s*VILLAGE\\b[:\\-\\s]*", RegexOption.IGNORE_CASE),
Regex("^\\s*TOWN\\b[:\\-\\s]*", RegexOption.IGNORE_CASE),
Regex("^\\s*CITY\\b[:\\-\\s]*", RegexOption.IGNORE_CASE),
Regex("^\\s*P\\.?\\s*O\\.?\\b[:\\-\\s]*", RegexOption.IGNORE_CASE),
Regex("^\\s*POST\\s*OFFICE\\b[:\\-\\s]*", RegexOption.IGNORE_CASE),
Regex("^\\s*P\\.?\\s*DIST\\.?\\b[:\\-\\s]*", RegexOption.IGNORE_CASE),
Regex("^\\s*DISTRICT\\b[:\\-\\s]*", RegexOption.IGNORE_CASE),
Regex("^\\s*DIST\\b[:\\-\\s]*", RegexOption.IGNORE_CASE),
Regex("^\\s*STATE\\b[:\\-\\s]*", RegexOption.IGNORE_CASE),
Regex("^\\s*PIN\\s*CODE\\b[:\\-\\s]*", RegexOption.IGNORE_CASE),
Regex("^\\s*PINCODE\\b[:\\-\\s]*", RegexOption.IGNORE_CASE),
Regex("^\\s*PIN\\b[:\\-\\s]*", RegexOption.IGNORE_CASE)
)
val dropPhrases = setOf(
"address", "addr", "area", "area was", "street/road/lane", "village/town/city", "colony"
)
val parts = raw.replace("\n", ",").split(",")
val cleanedParts = mutableListOf<String>()
for (part in parts) {
var value = part.trim()
if (value.isBlank()) continue
if (relationRegex.containsMatchIn(value)) continue
for (regex in prefixRegexes) {
value = regex.replace(value, "").trim()
}
value = value.replace(Regex("\\s+"), " ").trim()
if (value.isBlank()) continue
if (value.length < 3) continue
if (dropPhrases.contains(value.lowercase())) continue
cleanedParts.add(value)
}
return if (cleanedParts.isEmpty()) null else cleanedParts.joinToString(", ")
}

View File

@@ -1,47 +0,0 @@
package com.android.trisolarisserver.component.document
import com.android.trisolarisserver.component.sse.SseHub
import com.android.trisolarisserver.controller.guest.GuestDocumentResponse
import com.android.trisolarisserver.controller.guest.toResponse
import com.android.trisolarisserver.repo.guest.GuestDocumentRepo
import com.fasterxml.jackson.databind.ObjectMapper
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Component
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter
import java.util.UUID
@Component
class GuestDocumentEvents(
private val guestDocumentRepo: GuestDocumentRepo,
private val objectMapper: ObjectMapper
) {
private val hub = SseHub<GuestDocKey>("guest-documents") { key ->
buildSnapshot(key.propertyId, key.guestId)
}
fun subscribe(propertyId: UUID, guestId: UUID): SseEmitter {
val key = GuestDocKey(propertyId, guestId)
return hub.subscribe(key)
}
fun emit(propertyId: UUID, guestId: UUID) {
val key = GuestDocKey(propertyId, guestId)
hub.emit(key)
}
@Scheduled(fixedDelayString = "25000")
fun heartbeat() {
hub.heartbeat()
}
private fun buildSnapshot(propertyId: UUID, guestId: UUID): List<GuestDocumentResponse> {
return guestDocumentRepo
.findByPropertyIdAndGuestIdOrderByUploadedAtDesc(propertyId, guestId)
.map { it.toResponse(objectMapper) }
}
}
private data class GuestDocKey(
val propertyId: UUID,
val guestId: UUID
)

View File

@@ -1,95 +0,0 @@
package com.android.trisolarisserver.component.geo
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
import org.springframework.web.client.RestTemplate
import org.springframework.web.util.UriComponentsBuilder
@Component
class DataGovPincodeClient(
private val restTemplate: RestTemplate,
private val objectMapper: ObjectMapper,
@Value("\${pincode.datagov.apiKey:}")
private val apiKey: String,
@Value("\${pincode.datagov.baseUrl:https://api.data.gov.in/resource/5c2f62fe-5afa-4119-a499-fec9d604d5bd}")
private val baseUrl: String
) {
private val logger = LoggerFactory.getLogger(DataGovPincodeClient::class.java)
fun resolve(pinCode: String): PincodeLookupResult {
if (apiKey.isBlank()) return PincodeLookupResult(null, null, "NO_API_KEY", "data.gov.in", "Missing API key")
return try {
fetch(pinCode)
} catch (ex: Exception) {
logger.warn("Data.gov.in lookup failed: {}", ex.message)
PincodeLookupResult(null, null, "ERROR", "data.gov.in", ex.message)
}
}
private fun fetch(pinCodeValue: String): PincodeLookupResult {
val url = UriComponentsBuilder.fromUriString(baseUrl)
.queryParam("api-key", apiKey)
.queryParam("format", "json")
.queryParam("filters[pincode]", pinCodeValue)
.queryParam("offset", "0")
.queryParam("limit", "100")
.toUriString()
return try {
val response = restTemplate.getForEntity(url, String::class.java)
val body = response.body ?: return PincodeLookupResult(null, null, "EMPTY_BODY", "data.gov.in", requestUrl = url)
val parsed = parseCityState(body, pinCodeValue)
val status = when {
parsed != null -> "OK"
isFilterMismatch(body, pinCodeValue) -> "FILTER_MISMATCH"
else -> "ZERO_RESULTS"
}
val error = if (status == "FILTER_MISMATCH") "Records did not match pin filter" else null
PincodeLookupResult(parsed, body, status, "data.gov.in", error, url)
} catch (ex: Exception) {
PincodeLookupResult(null, null, "ERROR", "data.gov.in", ex.message, url)
}
}
private fun parseCityState(body: String, pinCodeValue: String): String? {
val root = objectMapper.readTree(body)
val records = root.path("records")
if (!records.isArray || records.isEmpty) return null
val filtered = records.filter { record ->
val recordPin = record.path("pincode").asText(null)
recordPin?.trim() == pinCodeValue
}
if (filtered.isEmpty()) return null
val chosen = chooseRecord(filtered) ?: return null
val district = chosen.path("district").asText(null)
val state = chosen.path("statename").asText(null)
val districtName = district?.let { toTitleCase(it) }
val stateName = state?.let { toTitleCase(it) }
if (districtName.isNullOrBlank() && stateName.isNullOrBlank()) return null
return listOfNotNull(districtName?.ifBlank { null }, stateName?.ifBlank { null }).joinToString(", ")
}
private fun chooseRecord(records: List<JsonNode>): JsonNode? {
val delivery = records.firstOrNull { it.path("delivery").asText("").equals("Delivery", true) }
return delivery ?: records.firstOrNull()
}
private fun isFilterMismatch(body: String, pinCodeValue: String): Boolean {
val root = objectMapper.readTree(body)
val records = root.path("records")
if (!records.isArray || records.isEmpty) return false
val anyMatch = records.any { record ->
val recordPin = record.path("pincode").asText(null)
recordPin?.trim() == pinCodeValue
}
return !anyMatch
}
private fun toTitleCase(value: String): String {
return value.lowercase().split(Regex("\\s+")).joinToString(" ") { word ->
word.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }
}
}
}

View File

@@ -1,110 +0,0 @@
package com.android.trisolarisserver.component.geo
import com.fasterxml.jackson.databind.ObjectMapper
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
import org.springframework.web.client.RestTemplate
import org.springframework.web.util.UriComponentsBuilder
@Component
class GoogleGeocodingClient(
private val restTemplate: RestTemplate,
private val objectMapper: ObjectMapper,
@Value("\${google.maps.apiKey:}")
private val apiKey: String,
@Value("\${google.maps.geocode.baseUrl:https://maps.googleapis.com/maps/api/geocode/json}")
private val baseUrl: String
) {
private val logger = LoggerFactory.getLogger(GoogleGeocodingClient::class.java)
fun resolveCityState(pinCode: String): GeocodeResult {
if (apiKey.isBlank()) {
return GeocodeResult(null, null, "NO_API_KEY")
}
return try {
val url = UriComponentsBuilder.fromUriString(baseUrl)
.queryParam("components", "postal_code:$pinCode|country:IN")
.queryParam("region", "IN")
.queryParam("key", apiKey)
.toUriString()
val primary = fetch(url)
if (primary.status == "OK") {
return primary
}
if (primary.status == "ZERO_RESULTS") {
val fallbackUrl = UriComponentsBuilder.fromUriString(baseUrl)
.queryParam("address", "$pinCode India")
.queryParam("region", "IN")
.queryParam("key", apiKey)
.toUriString()
val fallback = fetch(fallbackUrl)
if (fallback.resolvedCityState != null) return fallback
return primary
}
primary
} catch (ex: Exception) {
logger.warn("Geocoding failed: {}", ex.message)
GeocodeResult(null, null, "ERROR")
}
}
private fun fetch(url: String): GeocodeResult {
val response = restTemplate.getForEntity(url, String::class.java)
val body = response.body ?: return GeocodeResult(null, null, "EMPTY_BODY")
val status = parseStatus(body)
val parsed = if (status == "OK") parseCityState(body) else null
return GeocodeResult(parsed, body, status ?: "UNKNOWN_STATUS")
}
private fun parseStatus(body: String): String? {
return objectMapper.readTree(body).path("status").asText(null)
}
private fun parseCityState(body: String): String? {
val root = objectMapper.readTree(body)
val results = root.path("results")
if (!results.isArray || results.isEmpty) return null
val resultNode = results.firstOrNull { node ->
node.path("types").any { it.asText(null) == "postal_code" }
} ?: results.first()
val components = resultNode.path("address_components")
if (!components.isArray) return null
var city: String? = null
var admin2: String? = null
var state: String? = null
for (comp in components) {
val types = comp.path("types").mapNotNull { it.asText(null) }.toSet()
when {
"locality" in types -> city = comp.path("long_name").asText(null) ?: city
"postal_town" in types -> if (city == null) {
city = comp.path("long_name").asText(null)
}
"sublocality" in types -> if (city == null) {
city = comp.path("long_name").asText(null)
}
"administrative_area_level_2" in types -> admin2 = comp.path("long_name").asText(null) ?: admin2
"administrative_area_level_1" in types -> state = comp.path("long_name").asText(null) ?: state
}
}
val preferredCity = admin2?.trim()?.ifBlank { null } ?: city?.trim()?.ifBlank { null }
if (preferredCity == null && state.isNullOrBlank()) return null
return listOfNotNull(preferredCity, state?.trim()?.ifBlank { null }).joinToString(", ")
}
}
data class GeocodeResult(
val resolvedCityState: String?,
val rawResponse: String?,
val status: String?
)
data class PincodeLookupResult(
val resolvedCityState: String?,
val rawResponse: String?,
val status: String?,
val source: String,
val errorMessage: String? = null,
val requestUrl: String? = null
)

View File

@@ -1,100 +0,0 @@
package com.android.trisolarisserver.component.geo
import com.android.trisolarisserver.repo.property.IndiaPincodeCityStateRepo
import org.slf4j.LoggerFactory
import org.springframework.data.domain.PageRequest
import org.springframework.stereotype.Component
@Component
class PincodeResolver(
private val indiaPincodeCityStateRepo: IndiaPincodeCityStateRepo,
private val dataGovPincodeClient: DataGovPincodeClient,
private val postalPincodeClient: PostalPincodeClient,
private val googleGeocodingClient: GoogleGeocodingClient
) {
private val logger = LoggerFactory.getLogger(PincodeResolver::class.java)
fun resolve(pinCode: String): PincodeResolveResult {
val primary = resolveFromLocalDb(pinCode)
if (primary.status == "OK" && primary.resolvedCityState != null) {
return PincodeResolveResult(primary, null, null, null)
}
val secondary = dataGovPincodeClient.resolve(pinCode)
if (secondary.status == "OK" && secondary.resolvedCityState != null) {
return PincodeResolveResult(primary, secondary, null, null)
}
val tertiary = postalPincodeClient.resolve(pinCode)
if (tertiary.status == "OK" && tertiary.resolvedCityState != null) {
return PincodeResolveResult(primary, secondary, tertiary, null)
}
val google = googleGeocodingClient.resolveCityState(pinCode)
val quaternary = PincodeLookupResult(
google.resolvedCityState,
google.rawResponse,
google.status,
"google"
)
return PincodeResolveResult(primary, secondary, tertiary, quaternary)
}
private fun resolveFromLocalDb(pinCode: String): PincodeLookupResult {
val normalizedPin = pinCode.trim()
if (!PIN_REGEX.matches(normalizedPin)) {
return PincodeLookupResult(
resolvedCityState = null,
rawResponse = null,
status = "INVALID_PIN",
source = "local-db",
errorMessage = "PIN must be 6 digits"
)
}
return try {
val candidate = indiaPincodeCityStateRepo
.findCityStateCandidates(normalizedPin.toInt(), PageRequest.of(0, 1))
.firstOrNull()
if (candidate != null) {
PincodeLookupResult(
resolvedCityState = "${candidate.city}, ${candidate.state}",
rawResponse = null,
status = "OK",
source = "local-db"
)
} else {
PincodeLookupResult(
resolvedCityState = null,
rawResponse = null,
status = "ZERO_RESULTS",
source = "local-db"
)
}
} catch (ex: Exception) {
logger.warn("Local pincode lookup failed: {}", ex.message)
PincodeLookupResult(
resolvedCityState = null,
rawResponse = null,
status = "ERROR",
source = "local-db",
errorMessage = ex.message
)
}
}
companion object {
private val PIN_REGEX = Regex("\\d{6}")
}
}
data class PincodeResolveResult(
val primary: PincodeLookupResult,
val secondary: PincodeLookupResult?,
val tertiary: PincodeLookupResult?,
val quaternary: PincodeLookupResult?
) {
fun resolved(): PincodeLookupResult? {
return sequenceOf(primary, secondary, tertiary, quaternary).firstOrNull { it?.resolvedCityState != null }
}
}

View File

@@ -1,89 +0,0 @@
package com.android.trisolarisserver.component.geo
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import org.slf4j.LoggerFactory
import org.springframework.http.HttpEntity
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpMethod
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
import org.springframework.web.client.RestTemplate
import org.springframework.web.util.UriComponentsBuilder
@Component
class PostalPincodeClient(
private val restTemplate: RestTemplate,
private val objectMapper: ObjectMapper,
@Value("\${pincode.postal.baseUrl:https://api.postalpincode.in}")
private val baseUrl: String
) {
private val logger = LoggerFactory.getLogger(PostalPincodeClient::class.java)
fun resolve(pinCode: String): PincodeLookupResult {
val first = fetch(baseUrl, pinCode)
if (first.resolvedCityState != null) return first
if (first.status == "ERROR" && baseUrl.startsWith("https://")) {
val httpUrl = baseUrl.replaceFirst("https://", "http://")
val second = fetch(httpUrl, pinCode)
if (second.resolvedCityState != null) return second
return second
}
return first
}
private fun fetch(base: String, pinCode: String): PincodeLookupResult {
val url = UriComponentsBuilder.fromUriString(base)
.path("/pincode/{pin}")
.buildAndExpand(pinCode)
.toUriString()
val headers = HttpHeaders().apply {
set("User-Agent", "Mozilla/5.0 (TrisolarisServer)")
set("Accept", "application/json")
set("Connection", "close")
}
val entity = HttpEntity<Unit>(headers)
var lastError: Exception? = null
repeat(2) {
try {
val response = restTemplate.exchange(url, HttpMethod.GET, entity, String::class.java)
val body = response.body ?: return PincodeLookupResult(null, null, "EMPTY_BODY", "postalpincode.in", requestUrl = url)
val resolved = parseCityState(body)
val status = if (resolved == null) "ZERO_RESULTS" else "OK"
return PincodeLookupResult(resolved, body, status, "postalpincode.in", requestUrl = url)
} catch (ex: Exception) {
lastError = ex
try {
Thread.sleep(200)
} catch (_: InterruptedException) {
Thread.currentThread().interrupt()
}
}
}
val errorMessage = lastError?.message
if (errorMessage != null) {
logger.warn("Postalpincode lookup failed: {}", errorMessage)
}
return PincodeLookupResult(null, null, "ERROR", "postalpincode.in", errorMessage, url)
}
private fun parseCityState(body: String): String? {
val root = objectMapper.readTree(body)
if (!root.isArray || root.isEmpty) return null
val first = root.first()
val status = first.path("Status").asText(null)
if (!status.equals("Success", true)) return null
val offices = first.path("PostOffice")
if (!offices.isArray || offices.isEmpty) return null
val office = chooseOffice(offices) ?: return null
val district = office.path("District").asText(null)
val state = office.path("State").asText(null)
if (district.isNullOrBlank() && state.isNullOrBlank()) return null
return listOfNotNull(district?.ifBlank { null }, state?.ifBlank { null }).joinToString(", ")
}
private fun chooseOffice(offices: JsonNode): JsonNode? {
val delivery = offices.firstOrNull { it.path("DeliveryStatus").asText("").equals("Delivery", true) }
return delivery ?: offices.firstOrNull()
}
}

View File

@@ -1,49 +0,0 @@
package com.android.trisolarisserver.component.razorpay
import com.android.trisolarisserver.component.sse.SseHub
import com.android.trisolarisserver.controller.dto.razorpay.RazorpayQrEventResponse
import com.android.trisolarisserver.repo.razorpay.RazorpayQrRequestRepo
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Component
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter
import java.time.OffsetDateTime
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
@Component
class RazorpayQrEvents(
private val qrRequestRepo: RazorpayQrRequestRepo
) {
private val latestEvents: MutableMap<QrKey, RazorpayQrEventResponse> = ConcurrentHashMap()
private val hub = SseHub<QrKey>("qr") { key ->
latestEvents[key] ?: run {
val latest = qrRequestRepo.findTopByQrIdOrderByCreatedAtDesc(key.qrId)
RazorpayQrEventResponse(
event = "snapshot",
qrId = key.qrId,
status = latest?.status,
receivedAt = (latest?.createdAt ?: OffsetDateTime.now()).toString()
)
}
}
fun subscribe(propertyId: UUID, qrId: String): SseEmitter {
return hub.subscribe(QrKey(propertyId, qrId))
}
fun emit(propertyId: UUID, qrId: String, event: RazorpayQrEventResponse) {
val key = QrKey(propertyId, qrId)
latestEvents[key] = event
hub.emit(key)
}
@Scheduled(fixedDelayString = "25000")
fun heartbeat() {
hub.heartbeat()
}
}
private data class QrKey(
val propertyId: UUID,
val qrId: String
)

View File

@@ -1,52 +0,0 @@
package com.android.trisolarisserver.component.room
import com.android.trisolarisserver.component.sse.SseHub
import com.android.trisolarisserver.controller.dto.room.RoomBoardResponse
import com.android.trisolarisserver.controller.dto.room.RoomBoardStatus
import com.android.trisolarisserver.repo.room.RoomRepo
import com.android.trisolarisserver.repo.room.RoomStayRepo
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Component
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter
import java.util.UUID
@Component
class RoomBoardEvents(
private val roomRepo: RoomRepo,
private val roomStayRepo: RoomStayRepo
) {
private val hub = SseHub<UUID>("room-board") { propertyId ->
buildSnapshot(propertyId)
}
fun subscribe(propertyId: UUID): SseEmitter {
return hub.subscribe(propertyId)
}
fun emit(propertyId: UUID) {
hub.emit(propertyId)
}
@Scheduled(fixedDelayString = "25000")
fun heartbeat() {
hub.heartbeat()
}
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

@@ -1,58 +0,0 @@
package com.android.trisolarisserver.component.sse
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList
class SseHub<K>(
private val eventName: String,
private val snapshot: (K) -> Any
) {
private val emitters: MutableMap<K, CopyOnWriteArrayList<SseEmitter>> = ConcurrentHashMap()
fun subscribe(key: K): SseEmitter {
val emitter = SseEmitter(0L)
emitters.computeIfAbsent(key) { CopyOnWriteArrayList() }.add(emitter)
emitter.onCompletion { emitters[key]?.remove(emitter) }
emitter.onTimeout { emitters[key]?.remove(emitter) }
emitter.onError { emitters[key]?.remove(emitter) }
try {
emitter.send(SseEmitter.event().name(eventName).data(snapshot(key)))
} catch (_: Exception) {
emitters[key]?.remove(emitter)
}
return emitter
}
fun emit(key: K) {
val list = emitters[key] ?: return
val data = snapshot(key)
val dead = mutableListOf<SseEmitter>()
for (emitter in list) {
try {
emitter.send(SseEmitter.event().name(eventName).data(data))
} catch (_: Exception) {
dead.add(emitter)
}
}
if (dead.isNotEmpty()) {
list.removeAll(dead.toSet())
}
}
fun heartbeat() {
emitters.forEach { (_, list) ->
val dead = mutableListOf<SseEmitter>()
for (emitter in list) {
try {
emitter.send(SseEmitter.event().name("ping").data("ok"))
} catch (_: Exception) {
dead.add(emitter)
}
}
if (dead.isNotEmpty()) {
list.removeAll(dead.toSet())
}
}
}
}

View File

@@ -1,17 +0,0 @@
package com.android.trisolarisserver.component.storage
import java.nio.file.Files
import java.nio.file.Path
internal 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

@@ -1,44 +0,0 @@
package com.android.trisolarisserver.component.storage
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
import org.springframework.web.multipart.MultipartFile
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import java.util.UUID
data class StoredSignature(
val storagePath: String,
val sizeBytes: Long
)
@Component
class GuestSignatureStorage(
@Value("\${storage.documents.root}") private val root: String
) {
init {
val rootPath = Paths.get(root)
Files.createDirectories(rootPath)
if (!Files.isWritable(rootPath)) {
throw IllegalStateException("Guest signature root not writable: $root")
}
}
fun store(propertyId: UUID, guestId: UUID, file: MultipartFile): StoredSignature {
val dir = Paths.get(root, "guests", propertyId.toString(), guestId.toString())
Files.createDirectories(dir)
val path = dir.resolve("signature.svg")
file.inputStream.use { input ->
Files.newOutputStream(path).use { output -> input.copyTo(output) }
}
return StoredSignature(
storagePath = path.toString(),
sizeBytes = Files.size(path)
)
}
fun resolvePath(storagePath: String): Path {
return Paths.get(storagePath)
}
}

View File

@@ -1,4 +1,4 @@
package com.android.trisolarisserver.config.core
package com.android.trisolarisserver.config
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration

View File

@@ -1,4 +1,4 @@
package com.android.trisolarisserver.config.core
package com.android.trisolarisserver.config
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper

View File

@@ -1,78 +0,0 @@
package com.android.trisolarisserver.config.core
import jakarta.servlet.http.HttpServletRequest
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.security.access.AccessDeniedException
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice
import org.springframework.web.server.ResponseStatusException
import java.time.OffsetDateTime
@RestControllerAdvice
class ApiExceptionHandler {
@ExceptionHandler(ResponseStatusException::class)
fun handleResponseStatus(
ex: ResponseStatusException,
request: HttpServletRequest
): ResponseEntity<ApiError> {
val status = ex.statusCode as HttpStatus
return ResponseEntity.status(status)
.contentType(MediaType.APPLICATION_JSON)
.body(
ApiError(
timestamp = OffsetDateTime.now().toString(),
status = status.value(),
error = status.reasonPhrase,
message = ex.reason ?: "Request failed",
path = request.requestURI
)
)
}
@ExceptionHandler(AccessDeniedException::class)
fun handleAccessDenied(
ex: AccessDeniedException,
request: HttpServletRequest
): ResponseEntity<ApiError> {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.contentType(MediaType.APPLICATION_JSON)
.body(
ApiError(
timestamp = OffsetDateTime.now().toString(),
status = HttpStatus.FORBIDDEN.value(),
error = HttpStatus.FORBIDDEN.reasonPhrase,
message = ex.message ?: "Forbidden",
path = request.requestURI
)
)
}
@ExceptionHandler(Exception::class)
fun handleGeneric(
ex: Exception,
request: HttpServletRequest
): ResponseEntity<ApiError> {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.contentType(MediaType.APPLICATION_JSON)
.body(
ApiError(
timestamp = OffsetDateTime.now().toString(),
status = HttpStatus.INTERNAL_SERVER_ERROR.value(),
error = HttpStatus.INTERNAL_SERVER_ERROR.reasonPhrase,
message = ex.message ?: "Internal server error",
path = request.requestURI
)
)
}
}
data class ApiError(
val timestamp: String,
val status: Int,
val error: String,
val message: String,
val path: String
)

View File

@@ -0,0 +1,64 @@
package com.android.trisolarisserver.controller
import com.android.trisolarisserver.controller.dto.PropertyUserResponse
import com.android.trisolarisserver.controller.dto.UserResponse
import com.android.trisolarisserver.repo.AppUserRepo
import com.android.trisolarisserver.repo.PropertyUserRepo
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.PostMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.server.ResponseStatusException
import org.springframework.http.HttpStatus
@RestController
@RequestMapping("/auth")
class Auth(
private val appUserRepo: AppUserRepo,
private val propertyUserRepo: PropertyUserRepo
) {
@PostMapping("/verify")
fun verify(@AuthenticationPrincipal principal: MyPrincipal?): AuthResponse {
return buildAuthResponse(principal)
}
@GetMapping("/me")
fun me(@AuthenticationPrincipal principal: MyPrincipal?): AuthResponse {
return buildAuthResponse(principal)
}
private fun buildAuthResponse(principal: MyPrincipal?): AuthResponse {
if (principal == null) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
}
val user = appUserRepo.findById(principal.userId).orElseThrow {
ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found")
}
val memberships = propertyUserRepo.findByIdUserId(principal.userId).map {
PropertyUserResponse(
userId = it.id.userId!!,
propertyId = it.id.propertyId!!,
roles = it.roles.map { role -> role.name }.toSet()
)
}
return AuthResponse(
user = UserResponse(
id = user.id!!,
orgId = user.org.id!!,
firebaseUid = user.firebaseUid,
phoneE164 = user.phoneE164,
name = user.name,
disabled = user.disabled
),
properties = memberships
)
}
}
data class AuthResponse(
val user: UserResponse,
val properties: List<PropertyUserResponse>
)

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,265 @@
package com.android.trisolarisserver.controller
import com.android.trisolarisserver.component.DocumentStorage
import com.android.trisolarisserver.component.DocumentTokenService
import com.android.trisolarisserver.component.LlamaClient
import com.android.trisolarisserver.component.PropertyAccess
import com.android.trisolarisserver.db.repo.BookingRepo
import com.android.trisolarisserver.db.repo.GuestDocumentRepo
import com.android.trisolarisserver.db.repo.GuestRepo
import com.android.trisolarisserver.models.booking.GuestDocument
import com.android.trisolarisserver.models.property.Role
import com.android.trisolarisserver.repo.AppUserRepo
import com.android.trisolarisserver.repo.PropertyRepo
import com.android.trisolarisserver.security.MyPrincipal
import com.fasterxml.jackson.databind.ObjectMapper
import org.springframework.core.io.FileSystemResource
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.*
import org.springframework.web.multipart.MultipartFile
import org.springframework.web.server.ResponseStatusException
import java.time.OffsetDateTime
import java.nio.file.Files
import java.nio.file.Paths
import java.util.UUID
@RestController
@RequestMapping("/properties/{propertyId}/guests/{guestId}/documents")
class GuestDocuments(
private val propertyAccess: PropertyAccess,
private val propertyRepo: PropertyRepo,
private val guestRepo: GuestRepo,
private val bookingRepo: BookingRepo,
private val guestDocumentRepo: GuestDocumentRepo,
private val appUserRepo: AppUserRepo,
private val storage: DocumentStorage,
private val tokenService: DocumentTokenService,
private val llamaClient: LlamaClient,
private val objectMapper: ObjectMapper,
@org.springframework.beans.factory.annotation.Value("\${storage.documents.publicBaseUrl}")
private val publicBaseUrl: String
) {
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
fun uploadDocument(
@PathVariable propertyId: UUID,
@PathVariable guestId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestParam("bookingId") bookingId: UUID,
@RequestPart("file") file: MultipartFile
): GuestDocumentResponse {
val user = requireUser(principal)
propertyAccess.requireMember(propertyId, user.id!!)
propertyAccess.requireAnyRole(propertyId, user.id!!, Role.ADMIN, Role.MANAGER)
if (file.isEmpty) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "File is empty")
}
val contentType = file.contentType
if (contentType != null && contentType.startsWith("video/")) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Video files are not allowed")
}
val property = propertyRepo.findById(propertyId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
}
val guest = guestRepo.findById(guestId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Guest not found")
}
if (guest.org.id != property.org.id) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Guest not in property org")
}
val booking = bookingRepo.findById(bookingId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found")
}
if (booking.property.id != propertyId) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Booking not in property")
}
if (booking.primaryGuest?.id != guestId) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Booking not linked to guest")
}
val stored = storage.store(propertyId, guestId, bookingId, file)
val document = GuestDocument(
property = property,
guest = guest,
booking = booking,
uploadedBy = user,
originalFilename = stored.originalFilename,
contentType = stored.contentType,
sizeBytes = stored.sizeBytes,
storagePath = stored.storagePath
)
val saved = guestDocumentRepo.save(document)
runExtraction(saved, propertyId, guestId)
return guestDocumentRepo.save(saved).toResponse(objectMapper)
}
@GetMapping
fun listDocuments(
@PathVariable propertyId: UUID,
@PathVariable guestId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
): List<GuestDocumentResponse> {
requirePrincipal(principal)
propertyAccess.requireMember(propertyId, principal!!.userId)
propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN, Role.MANAGER)
return guestDocumentRepo
.findByPropertyIdAndGuestIdOrderByUploadedAtDesc(propertyId, guestId)
.map { it.toResponse(objectMapper) }
}
@GetMapping("/{documentId}/file")
fun downloadDocument(
@PathVariable propertyId: UUID,
@PathVariable guestId: UUID,
@PathVariable documentId: UUID,
@RequestParam(required = false) token: String?,
@AuthenticationPrincipal principal: MyPrincipal?
): ResponseEntity<FileSystemResource> {
if (token == null) {
requirePrincipal(principal)
propertyAccess.requireMember(propertyId, principal!!.userId)
propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN, Role.MANAGER)
} else if (!tokenService.validateToken(token, documentId.toString())) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid token")
}
val document = guestDocumentRepo.findByIdAndPropertyIdAndGuestId(documentId, propertyId, guestId)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Document not found")
val path = Paths.get(document.storagePath)
if (!Files.exists(path)) {
throw ResponseStatusException(HttpStatus.NOT_FOUND, "File missing")
}
val resource = FileSystemResource(path)
val type = document.contentType ?: MediaType.APPLICATION_OCTET_STREAM_VALUE
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(type))
.header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"${document.originalFilename}\"")
.contentLength(document.sizeBytes)
.body(resource)
}
private fun runExtraction(document: GuestDocument, propertyId: UUID, guestId: UUID) {
try {
val token = tokenService.createToken(document.id.toString())
val imageUrl =
"${publicBaseUrl}/properties/$propertyId/guests/$guestId/documents/${document.id}/file?token=$token"
val results = linkedMapOf<String, String>()
results["hasAadhar"] = llamaClient.ask(imageUrl, "CONTAINS AADHAAR? Answer YES or NO only.")
results["hasUidai"] = llamaClient.ask(imageUrl, "CONTAINS UIDAI? Answer YES or NO only.")
results["hasTransportDept"] = llamaClient.ask(imageUrl, "CONTAINS TRANSPORT DEPARTMENT? Answer YES or NO only.")
results["hasIncomeTaxDept"] = llamaClient.ask(imageUrl, "CONTAINS INCOME TAX DEPARTMENT? Answer YES or NO only.")
results["hasElectionCommission"] = llamaClient.ask(imageUrl, "CONTAINS ELECTION COMMISSION OF INDIA? Answer YES or NO only.")
results["hasDrivingLicence"] = llamaClient.ask(imageUrl, "CONTAINS DRIVING LICENCE? Answer YES or NO only.")
results["hasPassport"] = llamaClient.ask(imageUrl, "CONTAINS PASSPORT? Answer YES or NO only.")
results["hasPolice"] = llamaClient.ask(imageUrl, "CONTAINS POLICE? Answer YES or NO only.")
results["hasCourt"] = llamaClient.ask(imageUrl, "CONTAINS COURT? Answer YES or NO only.")
results["hasHighCourt"] = llamaClient.ask(imageUrl, "CONTAINS HIGH COURT? Answer YES or NO only.")
results["hasSupremeCourt"] = llamaClient.ask(imageUrl, "CONTAINS SUPREME COURT? Answer YES or NO only.")
results["hasJudiciary"] = llamaClient.ask(imageUrl, "CONTAINS JUDICIARY? Answer YES or NO only.")
results["hasAddress"] = llamaClient.ask(imageUrl, "ADDRESS PRESENT? Answer YES or NO only.")
results["hasGender"] = llamaClient.ask(imageUrl, "GENDER PRESENT? Answer YES or NO only.")
results["hasNationality"] = llamaClient.ask(imageUrl, "NATIONALITY PRESENT? Answer YES or NO only.")
results["name"] = llamaClient.ask(imageUrl, "NAME? Reply only the name or NONE.")
results["dob"] = llamaClient.ask(imageUrl, "DOB? Reply only date or NONE.")
results["idNumber"] = llamaClient.ask(imageUrl, "ID NUMBER? Reply only number or NONE.")
results["address"] = llamaClient.ask(imageUrl, "ADDRESS? Reply only address or NONE.")
results["vehicleNumber"] = llamaClient.ask(imageUrl, "VEHICLE NUMBER? Reply only number or NONE.")
results["isVehiclePhoto"] = llamaClient.ask(imageUrl, "IS THIS A VEHICLE PHOTO? Answer YES or NO only.")
results["pinCode"] = llamaClient.ask(imageUrl, "PIN CODE? Reply only pin or NONE.")
results["city"] = llamaClient.ask(imageUrl, "CITY? Reply only city or NONE.")
results["gender"] = llamaClient.ask(imageUrl, "GENDER? Reply only MALE/FEMALE/OTHER or NONE.")
results["nationality"] = llamaClient.ask(imageUrl, "NATIONALITY? Reply only nationality or NONE.")
results["docType"] = when {
results["hasCourt"].orEmpty().contains("YES", ignoreCase = true) ||
results["hasHighCourt"].orEmpty().contains("YES", ignoreCase = true) ||
results["hasSupremeCourt"].orEmpty().contains("YES", ignoreCase = true) ||
results["hasJudiciary"].orEmpty().contains("YES", ignoreCase = true) -> "COURT_ID"
results["hasPolice"].orEmpty().contains("YES", ignoreCase = true) -> "POLICE_ID"
results["hasPassport"].orEmpty().contains("YES", ignoreCase = true) -> "PASSPORT"
results["hasTransportDept"].orEmpty().contains("YES", ignoreCase = true) ||
results["hasDrivingLicence"].orEmpty().contains("YES", ignoreCase = true) -> "TRANSPORT"
results["hasIncomeTaxDept"].orEmpty().contains("YES", ignoreCase = true) -> "PAN"
results["hasElectionCommission"].orEmpty().contains("YES", ignoreCase = true) -> "VOTER_ID"
results["hasAadhar"].orEmpty().contains("YES", ignoreCase = true) ||
results["hasUidai"].orEmpty().contains("YES", ignoreCase = true) -> {
if (results["hasAddress"].orEmpty().contains("YES", ignoreCase = true)) "AADHAR_BACK" else "AADHAR_FRONT"
}
results["vehicleNumber"].orEmpty().isNotBlank() && !results["vehicleNumber"]!!.contains("NONE", true) -> "VEHICLE"
results["isVehiclePhoto"].orEmpty().contains("YES", ignoreCase = true) -> "VEHICLE_PHOTO"
else -> "UNKNOWN"
}
document.extractedData = objectMapper.writeValueAsString(results)
document.extractedAt = OffsetDateTime.now()
} catch (_: Exception) {
// Keep upload successful even if AI extraction fails.
}
}
private fun requirePrincipal(principal: MyPrincipal?) {
if (principal == null) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
}
}
private fun requireUser(principal: MyPrincipal?): com.android.trisolarisserver.models.property.AppUser {
if (principal == null) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
}
return appUserRepo.findById(principal.userId).orElseThrow {
ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found")
}
}
}
data class GuestDocumentResponse(
val id: UUID,
val propertyId: UUID,
val guestId: UUID,
val bookingId: UUID,
val uploadedByUserId: UUID,
val uploadedAt: String,
val originalFilename: String,
val contentType: String?,
val sizeBytes: Long,
val extractedData: Map<String, String>?,
val extractedAt: String?
)
private fun GuestDocument.toResponse(objectMapper: ObjectMapper): GuestDocumentResponse {
val id = id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Document id missing")
val extracted: Map<String, String>? = extractedData?.let {
try {
val raw = objectMapper.readValue(it, Map::class.java)
raw.entries.associate { entry ->
entry.key.toString() to (entry.value?.toString() ?: "")
}
} catch (_: Exception) {
null
}
}
return GuestDocumentResponse(
id = id,
propertyId = property.id!!,
guestId = guest.id!!,
bookingId = booking.id!!,
uploadedByUserId = uploadedBy.id!!,
uploadedAt = uploadedAt.toString(),
originalFilename = originalFilename,
contentType = contentType,
sizeBytes = sizeBytes,
extractedData = extracted,
extractedAt = extractedAt?.toString()
)
}

View File

@@ -1,16 +1,13 @@
package com.android.trisolarisserver.controller.guest
import com.android.trisolarisserver.controller.common.requireMember
import com.android.trisolarisserver.controller.common.requireProperty
import com.android.trisolarisserver.controller.common.requirePropertyGuest
package com.android.trisolarisserver.controller
import com.android.trisolarisserver.component.auth.PropertyAccess
import com.android.trisolarisserver.controller.dto.guest.GuestRatingCreateRequest
import com.android.trisolarisserver.controller.dto.guest.GuestRatingResponse
import com.android.trisolarisserver.repo.booking.BookingRepo
import com.android.trisolarisserver.repo.guest.GuestRatingRepo
import com.android.trisolarisserver.repo.guest.GuestRepo
import com.android.trisolarisserver.repo.property.AppUserRepo
import com.android.trisolarisserver.repo.property.PropertyRepo
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
@@ -45,9 +42,18 @@ class GuestRatings(
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: GuestRatingCreateRequest
): GuestRatingResponse {
val resolved = requireMember(propertyAccess, propertyId, principal)
requirePrincipal(principal)
propertyAccess.requireMember(propertyId, principal!!.userId)
val (property, guest) = requirePropertyGuest(propertyRepo, guestRepo, propertyId, guestId)
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")
@@ -64,12 +70,13 @@ class GuestRatings(
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(resolved.userId).orElse(null)
createdBy = appUserRepo.findById(principal.userId).orElse(null)
)
guestRatingRepo.save(rating)
return rating.toResponse()
@@ -81,9 +88,18 @@ class GuestRatings(
@PathVariable guestId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
): List<GuestRatingResponse> {
requireMember(propertyAccess, propertyId, principal)
requirePrincipal(principal)
propertyAccess.requireMember(propertyId, principal!!.userId)
val (_, guest) = requirePropertyGuest(propertyRepo, guestRepo, propertyId, guestId)
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() }
}
@@ -101,6 +117,7 @@ class GuestRatings(
private fun GuestRating.toResponse(): GuestRatingResponse {
return GuestRatingResponse(
id = id!!,
orgId = org.id!!,
propertyId = property.id!!,
guestId = guest.id!!,
bookingId = booking.id!!,
@@ -111,4 +128,9 @@ class GuestRatings(
)
}
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

@@ -1,4 +1,4 @@
package com.android.trisolarisserver.controller.system
package com.android.trisolarisserver.controller
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController
@@ -7,11 +7,11 @@ import org.springframework.web.bind.annotation.RestController
class Health {
@GetMapping("/health")
fun health(): Map<String, String> {
return mapOf("status" to "ok", "build" to "2026-01-26-authfix")
return mapOf("status" to "ok")
}
@GetMapping("/")
fun root(): Map<String, String> {
return mapOf("status" to "ok", "build" to "2026-01-26-authfix")
return mapOf("status" to "ok")
}
}

View File

@@ -1,16 +1,14 @@
package com.android.trisolarisserver.controller.email
import com.android.trisolarisserver.controller.common.requireMember
import com.android.trisolarisserver.controller.common.requirePrincipal
package com.android.trisolarisserver.controller
import com.android.trisolarisserver.component.storage.EmailStorage
import com.android.trisolarisserver.component.auth.PropertyAccess
import com.android.trisolarisserver.repo.email.InboundEmailRepo
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.property.PropertyRepo
import com.android.trisolarisserver.repo.PropertyRepo
import com.android.trisolarisserver.security.MyPrincipal
import com.android.trisolarisserver.service.email.EmailIngestionService
import com.android.trisolarisserver.service.EmailIngestionService
import org.apache.pdfbox.pdmodel.PDDocument
import org.apache.pdfbox.text.PDFTextStripper
import org.springframework.http.HttpStatus

View File

@@ -1,9 +1,7 @@
package com.android.trisolarisserver.controller.email
import com.android.trisolarisserver.controller.common.requireMember
import com.android.trisolarisserver.controller.common.requirePrincipal
package com.android.trisolarisserver.controller
import com.android.trisolarisserver.component.auth.PropertyAccess
import com.android.trisolarisserver.repo.email.InboundEmailRepo
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

View File

@@ -1,23 +1,17 @@
package com.android.trisolarisserver.controller.card
import com.android.trisolarisserver.controller.common.nowForProperty
import com.android.trisolarisserver.controller.common.parseOffset
import com.android.trisolarisserver.controller.common.requireMember
import com.android.trisolarisserver.controller.common.requireOpenRoomStayForProperty
import com.android.trisolarisserver.controller.common.requireRoomStayForProperty
package com.android.trisolarisserver.controller
import com.android.trisolarisserver.component.auth.PropertyAccess
import com.android.trisolarisserver.controller.dto.booking.CardPrepareRequest
import com.android.trisolarisserver.controller.dto.booking.CardPrepareResponse
import com.android.trisolarisserver.controller.dto.booking.CardRevokeResponse
import com.android.trisolarisserver.controller.dto.booking.IssueCardRequest
import com.android.trisolarisserver.controller.dto.booking.IssuedCardResponse
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.property.AppUserRepo
import com.android.trisolarisserver.repo.card.IssuedCardRepo
import com.android.trisolarisserver.repo.card.PropertyCardCounterRepo
import com.android.trisolarisserver.repo.property.PropertyRepo
import com.android.trisolarisserver.repo.room.RoomStayRepo
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
@@ -53,10 +47,18 @@ class IssuedCards(
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: CardPrepareRequest
): CardPrepareResponse {
val actor = requireIssueActor(propertyId, principal)
val stay = requireOpenRoomStayForProperty(roomStayRepo, propertyId, roomStayId, "Room stay closed")
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 = nowForProperty(stay.property.timezone)
val issuedAt = OffsetDateTime.now()
val expiresAt = request.expiresAt?.let { parseOffset(it) } ?: stay.toAt
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "expiresAt required")
if (!expiresAt.isAfter(issuedAt)) {
@@ -70,10 +72,7 @@ class IssuedCards(
key = payload.key,
timeData = payload.timeData,
issuedAt = issuedAt.toString(),
expiresAt = expiresAt.toString(),
sector3Block0 = encodeBlock(actor.name),
sector3Block1 = encodeBlock(actor.id?.toString()),
sector3Block2 = encodeBlock(null)
expiresAt = expiresAt.toString()
)
}
@@ -93,8 +92,16 @@ class IssuedCards(
if (request.cardIndex <= 0) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "cardIndex required")
}
val stay = requireOpenRoomStayForProperty(roomStayRepo, propertyId, roomStayId, "Room stay closed")
val issuedAt = parseOffset(request.issuedAt) ?: nowForProperty(stay.property.timezone)
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)) {
@@ -127,43 +134,40 @@ class IssuedCards(
@PathVariable roomStayId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
): List<IssuedCardResponse> {
requireViewActor(propertyId, principal)
val stay = requireRoomStayForProperty(roomStayRepo, propertyId, roomStayId)
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/{cardIndex}/revoke")
@org.springframework.transaction.annotation.Transactional
@PostMapping("/cards/{cardId}/revoke")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun revoke(
@PathVariable propertyId: UUID,
@PathVariable cardIndex: Int,
@PathVariable cardId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
): CardRevokeResponse {
val card = issuedCardRepo.findByPropertyIdAndCardIndex(propertyId, cardIndex)
) {
requireRevokeActor(propertyId, principal)
val card = issuedCardRepo.findByIdAndPropertyId(cardId, propertyId)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Card not found")
requireRevokeActor(propertyId, principal, card.roomStay == null)
if (card.revokedAt == null) {
val now = nowForProperty(card.property.timezone)
card.revokedAt = now
card.expiresAt = now
card.revokedAt = OffsetDateTime.now()
issuedCardRepo.save(card)
}
val key = buildSector0Block2(card.room.roomNumber, card.cardIndex)
val timeData = buildSector0TimeData(card.issuedAt, card.expiresAt, key)
return CardRevokeResponse(timeData = timeData)
}
@GetMapping("/cards/{cardIndex}")
fun getCardByIndex(
@PathVariable propertyId: UUID,
@PathVariable cardIndex: Int,
@AuthenticationPrincipal principal: MyPrincipal?
): IssuedCardResponse {
requireCardAdminActor(propertyId, principal)
val card = issuedCardRepo.findByPropertyIdAndCardIndex(propertyId, cardIndex)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Card not found")
return card.toResponse()
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?) {
@@ -173,20 +177,6 @@ class IssuedCards(
propertyAccess.requireMember(propertyId, principal.userId)
}
private fun requireViewActor(propertyId: UUID, principal: MyPrincipal?) {
if (principal == null) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
}
propertyAccess.requireAnyRole(
propertyId,
principal.userId,
Role.STAFF,
Role.ADMIN,
Role.MANAGER,
Role.SUPERVISOR
)
}
private fun requireIssueActor(propertyId: UUID, principal: MyPrincipal?): com.android.trisolarisserver.models.property.AppUser {
if (principal == null) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
@@ -198,27 +188,14 @@ class IssuedCards(
}
}
private fun requireRevokeActor(propertyId: UUID, principal: MyPrincipal?, isTempCard: Boolean) {
private fun requireRevokeActor(propertyId: UUID, principal: MyPrincipal?) {
if (principal == null) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
}
propertyAccess.requireMember(propertyId, principal.userId)
if (isTempCard) {
propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN, Role.MANAGER)
} else {
propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN)
}
propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN)
}
private fun requireCardAdminActor(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, Role.MANAGER)
}
private fun nextCardIndex(propertyId: UUID): Int {
var counter = counterRepo.findByPropertyIdForUpdate(propertyId)
if (counter == null) {
@@ -227,7 +204,7 @@ class IssuedCards(
}
counterRepo.save(com.android.trisolarisserver.models.room.PropertyCardCounter(
property = property,
nextIndex = 10001
nextIndex = 1
))
counter = counterRepo.findByPropertyIdForUpdate(propertyId)
}
@@ -245,18 +222,59 @@ class IssuedCards(
expiresAt: OffsetDateTime
): Sector0Payload {
val key = buildSector0Block2(roomNumber, cardIndex)
val finalData = buildSector0TimeData(issuedAt, expiresAt, key)
val newData = "0000000000" + formatDateComponents(issuedAt) + formatDateComponents(expiresAt)
val checkSum = calculateChecksum(key + newData)
val finalData = newData + checkSum
return Sector0Payload(key, finalData)
}
private fun buildSector0TimeData(
issuedAt: OffsetDateTime,
expiresAt: OffsetDateTime,
key: String? = null
): String {
val newData = "0000000000" + formatDateComponents(issuedAt) + formatDateComponents(expiresAt)
val checkSum = calculateChecksum((key ?: "") + newData)
return newData + checkSum
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
}
}
@@ -264,3 +282,17 @@ 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

@@ -0,0 +1,95 @@
package com.android.trisolarisserver.controller
import com.android.trisolarisserver.controller.dto.OrgCreateRequest
import com.android.trisolarisserver.controller.dto.OrgResponse
import com.android.trisolarisserver.repo.AppUserRepo
import com.android.trisolarisserver.repo.OrganizationRepo
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.Role
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("/orgs")
class Orgs(
private val orgRepo: OrganizationRepo,
private val appUserRepo: AppUserRepo,
private val propertyUserRepo: PropertyUserRepo
) {
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
fun createOrg(
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: OrgCreateRequest
): OrgResponse {
val user = requireUser(principal)
val orgId = user.org.id ?: throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Org missing")
if (!propertyUserRepo.hasAnyRoleInOrg(orgId, user.id!!, setOf(Role.ADMIN))) {
throw ResponseStatusException(HttpStatus.FORBIDDEN, "Missing role")
}
val org = Organization().apply {
name = request.name
emailAliases = request.emailAliases?.toMutableSet() ?: mutableSetOf()
if (request.allowedTransportModes != null) {
allowedTransportModes = parseTransportModes(request.allowedTransportModes)
}
}
val saved = orgRepo.save(org)
return OrgResponse(
id = saved.id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Org id missing"),
name = saved.name ?: "",
emailAliases = saved.emailAliases.toSet(),
allowedTransportModes = saved.allowedTransportModes.map { it.name }.toSet()
)
}
@GetMapping("/{orgId}")
fun getOrg(
@PathVariable orgId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
): OrgResponse {
val user = requireUser(principal)
if (user.org.id != orgId) {
throw ResponseStatusException(HttpStatus.FORBIDDEN, "No access to org")
}
val org = orgRepo.findById(orgId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Org not found")
}
return OrgResponse(
id = org.id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Org id missing"),
name = org.name ?: "",
emailAliases = org.emailAliases.toSet(),
allowedTransportModes = org.allowedTransportModes.map { it.name }.toSet()
)
}
private fun requireUser(principal: MyPrincipal?): com.android.trisolarisserver.models.property.AppUser {
if (principal == null) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
}
return appUserRepo.findById(principal.userId).orElseThrow {
ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found")
}
}
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

@@ -0,0 +1,286 @@
package com.android.trisolarisserver.controller
import com.android.trisolarisserver.component.PropertyAccess
import com.android.trisolarisserver.controller.dto.PropertyCreateRequest
import com.android.trisolarisserver.controller.dto.PropertyResponse
import com.android.trisolarisserver.controller.dto.PropertyUpdateRequest
import com.android.trisolarisserver.controller.dto.PropertyUserResponse
import com.android.trisolarisserver.controller.dto.PropertyUserRoleRequest
import com.android.trisolarisserver.controller.dto.UserResponse
import com.android.trisolarisserver.repo.AppUserRepo
import com.android.trisolarisserver.repo.OrganizationRepo
import com.android.trisolarisserver.repo.PropertyRepo
import com.android.trisolarisserver.repo.PropertyUserRepo
import com.android.trisolarisserver.models.property.Property
import com.android.trisolarisserver.models.property.PropertyUser
import com.android.trisolarisserver.models.property.PropertyUserId
import com.android.trisolarisserver.models.property.Role
import com.android.trisolarisserver.security.MyPrincipal
import com.android.trisolarisserver.models.booking.TransportMode
import org.springframework.http.HttpStatus
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.DeleteMapping
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.PutMapping
import org.springframework.web.bind.annotation.RequestBody
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
class Properties(
private val propertyAccess: PropertyAccess,
private val propertyRepo: PropertyRepo,
private val orgRepo: OrganizationRepo,
private val propertyUserRepo: PropertyUserRepo,
private val appUserRepo: AppUserRepo
) {
@PostMapping("/orgs/{orgId}/properties")
@ResponseStatus(HttpStatus.CREATED)
fun createProperty(
@PathVariable orgId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: PropertyCreateRequest
): PropertyResponse {
val user = requireUser(principal)
if (user.org.id != orgId) {
throw ResponseStatusException(HttpStatus.FORBIDDEN, "No access to org")
}
requireOrgRole(orgId, user.id!!, Role.ADMIN)
if (propertyRepo.existsByOrgIdAndCode(orgId, request.code)) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Property code already exists for org")
}
val org = orgRepo.findById(orgId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Org not found")
}
val property = Property(
org = org,
code = request.code,
name = request.name,
addressText = request.addressText,
timezone = request.timezone ?: "Asia/Kolkata",
currency = request.currency ?: "INR",
active = request.active ?: true,
otaAliases = request.otaAliases?.toMutableSet() ?: mutableSetOf(),
emailAddresses = request.emailAddresses?.toMutableSet() ?: mutableSetOf(),
allowedTransportModes = request.allowedTransportModes?.let { parseTransportModes(it) } ?: mutableSetOf()
)
val saved = propertyRepo.save(property)
return saved.toResponse()
}
@GetMapping("/orgs/{orgId}/properties")
fun listProperties(
@PathVariable orgId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
): List<PropertyResponse> {
val user = requireUser(principal)
if (user.org.id != orgId) {
throw ResponseStatusException(HttpStatus.FORBIDDEN, "No access to org")
}
val propertyIds = propertyUserRepo.findPropertyIdsByOrgAndUser(orgId, user.id!!)
return propertyRepo.findAllById(propertyIds).map { it.toResponse() }
}
@GetMapping("/orgs/{orgId}/users")
fun listUsers(
@PathVariable orgId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
): List<UserResponse> {
val user = requireUser(principal)
if (user.org.id != orgId) {
throw ResponseStatusException(HttpStatus.FORBIDDEN, "No access to org")
}
requireOrgRole(orgId, user.id!!, Role.ADMIN, Role.MANAGER)
return appUserRepo.findByOrgId(orgId).map { it.toUserResponse() }
}
@GetMapping("/properties/{propertyId}/users")
fun listPropertyUsers(
@PathVariable propertyId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
): List<PropertyUserResponse> {
requirePrincipal(principal)
propertyAccess.requireMember(propertyId, principal!!.userId)
propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN, Role.MANAGER)
val users = propertyUserRepo.findByIdPropertyId(propertyId)
return users.map {
PropertyUserResponse(
userId = it.id.userId!!,
propertyId = it.id.propertyId!!,
roles = it.roles.map { role -> role.name }.toSet()
)
}
}
@PutMapping("/properties/{propertyId}/users/{userId}/roles")
fun upsertPropertyUserRoles(
@PathVariable propertyId: UUID,
@PathVariable userId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: PropertyUserRoleRequest
): PropertyUserResponse {
requirePrincipal(principal)
propertyAccess.requireMember(propertyId, principal!!.userId)
val actorRoles = propertyUserRepo.findRolesByPropertyAndUser(propertyId, principal.userId)
val allowedRoles = when {
actorRoles.contains(Role.ADMIN) -> setOf(Role.ADMIN, Role.MANAGER, Role.STAFF, Role.AGENT)
actorRoles.contains(Role.MANAGER) -> setOf(Role.STAFF, Role.AGENT)
else -> emptySet()
}
if (allowedRoles.isEmpty()) {
throw ResponseStatusException(HttpStatus.FORBIDDEN, "Missing role")
}
val requestedRoles = try {
request.roles.map { Role.valueOf(it) }.toSet()
} catch (ex: IllegalArgumentException) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown role")
}
if (!allowedRoles.containsAll(requestedRoles)) {
throw ResponseStatusException(HttpStatus.FORBIDDEN, "Role not allowed")
}
val property = propertyRepo.findById(propertyId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
}
val targetUser = appUserRepo.findById(userId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "User not found")
}
if (targetUser.org.id != property.org.id) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "User not in property org")
}
val propertyUser = PropertyUser(
id = PropertyUserId(propertyId = propertyId, userId = userId),
property = property,
user = targetUser,
roles = requestedRoles.toMutableSet()
)
val saved = propertyUserRepo.save(propertyUser)
return PropertyUserResponse(
userId = saved.id.userId!!,
propertyId = saved.id.propertyId!!,
roles = saved.roles.map { it.name }.toSet()
)
}
@DeleteMapping("/properties/{propertyId}/users/{userId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun deletePropertyUser(
@PathVariable propertyId: UUID,
@PathVariable userId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
) {
requirePrincipal(principal)
propertyAccess.requireMember(propertyId, principal!!.userId)
propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN)
val id = PropertyUserId(propertyId = propertyId, userId = userId)
if (propertyUserRepo.existsById(id)) {
propertyUserRepo.deleteById(id)
}
}
@PutMapping("/properties/{propertyId}")
fun updateProperty(
@PathVariable propertyId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: PropertyUpdateRequest
): PropertyResponse {
requirePrincipal(principal)
propertyAccess.requireMember(propertyId, principal!!.userId)
propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN)
val property = propertyRepo.findById(propertyId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
}
if (propertyRepo.existsByOrgIdAndCodeAndIdNot(property.org.id!!, request.code, propertyId)) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Property code already exists for org")
}
property.code = request.code
property.name = request.name
property.addressText = request.addressText ?: property.addressText
property.timezone = request.timezone ?: property.timezone
property.currency = request.currency ?: property.currency
property.active = request.active ?: property.active
if (request.otaAliases != null) {
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()
}
private fun requirePrincipal(principal: MyPrincipal?) {
if (principal == null) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
}
}
private fun requireUser(principal: MyPrincipal?): com.android.trisolarisserver.models.property.AppUser {
if (principal == null) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
}
return appUserRepo.findById(principal.userId).orElseThrow {
ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found")
}
}
private fun requireOrgRole(orgId: UUID, userId: UUID, vararg roles: Role) {
if (!propertyUserRepo.hasAnyRoleInOrg(orgId, userId, roles.toSet())) {
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 {
val id = id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Property id missing")
val orgId = org.id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Org id missing")
return PropertyResponse(
id = id,
orgId = orgId,
code = code,
name = name,
addressText = addressText,
timezone = timezone,
currency = currency,
active = active,
otaAliases = otaAliases.toSet(),
emailAddresses = emailAddresses.toSet(),
allowedTransportModes = allowedTransportModes.map { it.name }.toSet()
)
}
private fun com.android.trisolarisserver.models.property.AppUser.toUserResponse(): UserResponse {
val id = this.id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "User id missing")
val orgId = this.org.id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Org id missing")
return UserResponse(
id = id,
orgId = orgId,
firebaseUid = firebaseUid,
phoneE164 = phoneE164,
name = name,
disabled = disabled
)
}

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

@@ -0,0 +1,141 @@
package com.android.trisolarisserver.controller
import com.android.trisolarisserver.component.PropertyAccess
import com.android.trisolarisserver.controller.dto.RoomTypeResponse
import com.android.trisolarisserver.controller.dto.RoomTypeUpsertRequest
import com.android.trisolarisserver.repo.PropertyRepo
import com.android.trisolarisserver.repo.RoomRepo
import com.android.trisolarisserver.repo.RoomTypeRepo
import com.android.trisolarisserver.models.property.Role
import com.android.trisolarisserver.models.room.RoomType
import com.android.trisolarisserver.security.MyPrincipal
import org.springframework.http.HttpStatus
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.DeleteMapping
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.PutMapping
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}/room-types")
class RoomTypes(
private val propertyAccess: PropertyAccess,
private val roomTypeRepo: RoomTypeRepo,
private val roomRepo: RoomRepo,
private val propertyRepo: PropertyRepo
) {
@GetMapping
fun listRoomTypes(
@PathVariable propertyId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
): List<RoomTypeResponse> {
requirePrincipal(principal)
propertyAccess.requireMember(propertyId, principal!!.userId)
return roomTypeRepo.findByPropertyIdOrderByCode(propertyId).map { it.toResponse() }
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
fun createRoomType(
@PathVariable propertyId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: RoomTypeUpsertRequest
): RoomTypeResponse {
requirePrincipal(principal)
propertyAccess.requireMember(propertyId, principal!!.userId)
propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN, Role.MANAGER)
if (roomTypeRepo.existsByPropertyIdAndCode(propertyId, request.code)) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Room type code already exists for property")
}
val property = propertyRepo.findById(propertyId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
}
val roomType = RoomType(
property = property,
code = request.code,
name = request.name,
baseOccupancy = request.baseOccupancy ?: 2,
maxOccupancy = request.maxOccupancy ?: 3,
otaAliases = request.otaAliases?.toMutableSet() ?: mutableSetOf()
)
return roomTypeRepo.save(roomType).toResponse()
}
@PutMapping("/{roomTypeId}")
fun updateRoomType(
@PathVariable propertyId: UUID,
@PathVariable roomTypeId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: RoomTypeUpsertRequest
): RoomTypeResponse {
requirePrincipal(principal)
propertyAccess.requireMember(propertyId, principal!!.userId)
propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN, Role.MANAGER)
val roomType = roomTypeRepo.findByIdAndPropertyId(roomTypeId, propertyId)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room type not found")
if (roomTypeRepo.existsByPropertyIdAndCodeAndIdNot(propertyId, request.code, roomTypeId)) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Room type code already exists for property")
}
roomType.code = request.code
roomType.name = request.name
roomType.baseOccupancy = request.baseOccupancy ?: roomType.baseOccupancy
roomType.maxOccupancy = request.maxOccupancy ?: roomType.maxOccupancy
if (request.otaAliases != null) {
roomType.otaAliases = request.otaAliases.toMutableSet()
}
return roomTypeRepo.save(roomType).toResponse()
}
@DeleteMapping("/{roomTypeId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun deleteRoomType(
@PathVariable propertyId: UUID,
@PathVariable roomTypeId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
) {
requirePrincipal(principal)
propertyAccess.requireMember(propertyId, principal!!.userId)
propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN, Role.MANAGER)
val roomType = roomTypeRepo.findByIdAndPropertyId(roomTypeId, propertyId)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room type not found")
if (roomRepo.existsByPropertyIdAndRoomTypeId(propertyId, roomTypeId)) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Cannot delete room type with rooms")
}
roomTypeRepo.delete(roomType)
}
private fun requirePrincipal(principal: MyPrincipal?) {
if (principal == null) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
}
}
}
private fun RoomType.toResponse(): RoomTypeResponse {
val id = id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Room type id missing")
val propertyId = property.id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Property id missing")
return RoomTypeResponse(
id = id,
propertyId = propertyId,
code = code,
name = name,
baseOccupancy = baseOccupancy,
maxOccupancy = maxOccupancy,
otaAliases = otaAliases.toSet()
)
}

View File

@@ -0,0 +1,266 @@
package com.android.trisolarisserver.controller
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.RoomBoardResponse
import com.android.trisolarisserver.controller.dto.RoomBoardStatus
import com.android.trisolarisserver.controller.dto.RoomResponse
import com.android.trisolarisserver.controller.dto.RoomUpsertRequest
import com.android.trisolarisserver.repo.PropertyRepo
import com.android.trisolarisserver.repo.PropertyUserRepo
import com.android.trisolarisserver.repo.RoomRepo
import com.android.trisolarisserver.repo.RoomStayRepo
import com.android.trisolarisserver.repo.RoomTypeRepo
import com.android.trisolarisserver.models.room.Room
import com.android.trisolarisserver.models.property.Role
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.PutMapping
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 org.springframework.web.servlet.mvc.method.annotation.SseEmitter
import java.time.LocalDate
import java.time.ZoneId
import java.util.UUID
@RestController
@RequestMapping("/properties/{propertyId}/rooms")
class Rooms(
private val propertyAccess: PropertyAccess,
private val roomRepo: RoomRepo,
private val roomStayRepo: RoomStayRepo,
private val propertyRepo: PropertyRepo,
private val roomTypeRepo: RoomTypeRepo,
private val propertyUserRepo: PropertyUserRepo,
private val roomBoardEvents: RoomBoardEvents
) {
@GetMapping
fun listRooms(
@PathVariable propertyId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
): List<RoomResponse> {
requirePrincipal(principal)
propertyAccess.requireMember(propertyId, principal!!.userId)
val roles = propertyUserRepo.findRolesByPropertyAndUser(propertyId, principal.userId)
val rooms = roomRepo.findByPropertyIdOrderByRoomNumber(propertyId)
if (isAgentOnly(roles)) {
val occupiedRoomIds = roomStayRepo.findOccupiedRoomIds(propertyId).toHashSet()
return rooms
.filter { it.active && !it.maintenance && !occupiedRoomIds.contains(it.id) }
.map { it.toRoomResponse() }
}
return rooms
.map { it.toRoomResponse() }
}
@GetMapping("/board")
fun roomBoard(
@PathVariable propertyId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
): List<RoomBoardResponse> {
requirePrincipal(principal)
propertyAccess.requireMember(propertyId, principal!!.userId)
val rooms = roomRepo.findByPropertyIdOrderByRoomNumber(propertyId)
val occupiedRoomIds = roomStayRepo.findOccupiedRoomIds(propertyId).toHashSet()
val roles = propertyUserRepo.findRolesByPropertyAndUser(propertyId, principal.userId)
val mapped = 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
)
}
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")
fun roomAvailability(
@PathVariable propertyId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
): List<RoomAvailabilityResponse> {
requirePrincipal(principal)
propertyAccess.requireMember(propertyId, principal!!.userId)
val rooms = roomRepo.findByPropertyIdOrderByRoomNumber(propertyId)
val occupiedRoomIds = roomStayRepo.findOccupiedRoomIds(propertyId).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) ->
RoomAvailabilityResponse(
roomTypeName = typeName,
freeRoomNumbers = roomList.map { it.roomNumber }
)
}.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
@ResponseStatus(HttpStatus.CREATED)
fun createRoom(
@PathVariable propertyId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: RoomUpsertRequest
): RoomResponse {
requirePrincipal(principal)
propertyAccess.requireMember(propertyId, principal!!.userId)
if (roomRepo.existsByPropertyIdAndRoomNumber(propertyId, request.roomNumber)) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Room number already exists for property")
}
val property = propertyRepo.findById(propertyId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
}
val roomType = roomTypeRepo.findByIdAndPropertyId(request.roomTypeId, propertyId)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room type not found")
val room = Room(
property = property,
roomType = roomType,
roomNumber = request.roomNumber,
floor = request.floor,
hasNfc = request.hasNfc,
active = request.active,
maintenance = request.maintenance,
notes = request.notes
)
val saved = roomRepo.save(room).toRoomResponse()
roomBoardEvents.emit(propertyId)
return saved
}
@PutMapping("/{roomId}")
fun updateRoom(
@PathVariable propertyId: UUID,
@PathVariable roomId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: RoomUpsertRequest
): RoomResponse {
requirePrincipal(principal)
propertyAccess.requireMember(propertyId, principal!!.userId)
val room = roomRepo.findByIdAndPropertyId(roomId, propertyId)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room not found for property")
if (roomRepo.existsByPropertyIdAndRoomNumberAndIdNot(propertyId, request.roomNumber, roomId)) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Room number already exists for property")
}
val roomType = roomTypeRepo.findByIdAndPropertyId(request.roomTypeId, propertyId)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room type not found")
room.roomNumber = request.roomNumber
room.floor = request.floor
room.roomType = roomType
room.hasNfc = request.hasNfc
room.active = request.active
room.maintenance = request.maintenance
room.notes = request.notes
val saved = roomRepo.save(room).toRoomResponse()
roomBoardEvents.emit(propertyId)
return saved
}
private fun requirePrincipal(principal: MyPrincipal?) {
if (principal == null) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
}
}
private fun isAgentOnly(roles: Set<Role>): Boolean {
if (!roles.contains(Role.AGENT)) return false
val privileged = setOf(Role.ADMIN, Role.MANAGER, Role.STAFF, Role.HOUSEKEEPING, Role.FINANCE)
return roles.none { it in privileged }
}
private fun parseDate(value: String): LocalDate {
return try {
LocalDate.parse(value.trim())
} catch (_: Exception) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid date format")
}
}
}
private fun Room.toRoomResponse(): RoomResponse {
val roomId = id ?: throw IllegalStateException("Room id is null")
val roomTypeId = roomType.id ?: throw IllegalStateException("Room type id is null")
return RoomResponse(
id = roomId,
roomNumber = roomNumber,
floor = floor,
roomTypeId = roomTypeId,
roomTypeName = roomType.name,
hasNfc = hasNfc,
active = active,
maintenance = maintenance,
notes = notes
)
}

View File

@@ -1,11 +1,9 @@
package com.android.trisolarisserver.controller.transport
import com.android.trisolarisserver.controller.common.requireMember
import com.android.trisolarisserver.controller.common.requirePrincipal
package com.android.trisolarisserver.controller
import com.android.trisolarisserver.component.auth.PropertyAccess
import com.android.trisolarisserver.controller.dto.property.TransportModeStatusResponse
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.property.PropertyRepo
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
@@ -33,10 +31,10 @@ class TransportModes(
val property = propertyRepo.findById(propertyId).orElseThrow {
ResponseStatusException(org.springframework.http.HttpStatus.NOT_FOUND, "Property not found")
}
val allowed = if (property.allowedTransportModes.isNotEmpty()) {
property.allowedTransportModes
} else {
TransportMode.entries.toSet()
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(

View File

@@ -1,54 +0,0 @@
package com.android.trisolarisserver.controller.assets
import org.springframework.beans.factory.annotation.Value
import org.springframework.core.io.FileSystemResource
import org.springframework.http.HttpHeaders
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
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 java.nio.file.Files
import java.nio.file.Paths
@RestController
@RequestMapping("/icons")
class IconFiles(
@Value("\${storage.icons.png.root:/home/androidlover5842/docs/icons/png}")
private val pngRoot: String
) {
@GetMapping("/png")
fun listPng(): List<String> {
val dir = Paths.get(pngRoot)
if (!Files.exists(dir) || !Files.isDirectory(dir)) {
return emptyList()
}
Files.newDirectoryStream(dir).use { stream ->
return stream
.filter { Files.isRegularFile(it) }
.map { it.fileName.toString() }
.filter { it.lowercase().endsWith(".png") }
.sorted()
.toList()
}
}
@GetMapping("/png/{filename}")
fun getPng(@PathVariable filename: String): ResponseEntity<FileSystemResource> {
if (filename.contains("..") || filename.contains("/") || filename.contains("\\")) {
return ResponseEntity.badRequest().build()
}
val file = Paths.get(pngRoot, filename)
if (!Files.exists(file) || !Files.isRegularFile(file)) {
return ResponseEntity.notFound().build()
}
val resource = FileSystemResource(file)
return ResponseEntity.ok()
.contentType(MediaType.IMAGE_PNG)
.header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"${file.fileName}\"")
.contentLength(resource.contentLength())
.body(resource)
}
}

View File

@@ -1,127 +0,0 @@
package com.android.trisolarisserver.controller.auth
import com.android.trisolarisserver.controller.dto.property.PropertyUserResponse
import com.android.trisolarisserver.controller.dto.property.UserResponse
import com.android.trisolarisserver.repo.property.AppUserRepo
import com.android.trisolarisserver.repo.property.PropertyUserRepo
import com.android.trisolarisserver.security.MyPrincipal
import jakarta.servlet.http.HttpServletRequest
import org.slf4j.LoggerFactory
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.server.ResponseStatusException
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import java.util.UUID
import com.android.trisolarisserver.security.AuthResolver
@RestController
@RequestMapping("/auth")
class Auth(
private val appUserRepo: AppUserRepo,
private val propertyUserRepo: PropertyUserRepo,
private val authResolver: AuthResolver
) {
private val logger = LoggerFactory.getLogger(Auth::class.java)
@PostMapping("/verify")
fun verify(
@AuthenticationPrincipal principal: MyPrincipal?,
request: HttpServletRequest
): ResponseEntity<AuthResponse> {
logger.info("Auth verify hit, principalPresent={}", principal != null)
val resolved = principal?.let { ResolveResult(it) }
?: ResolveResult(authResolver.resolveFromRequest(request, createIfMissing = true))
return resolved.toResponseEntity()
}
@GetMapping("/me")
fun me(
@AuthenticationPrincipal principal: MyPrincipal?,
request: HttpServletRequest
): ResponseEntity<AuthResponse> {
val resolved = principal?.let { ResolveResult(it) }
?: ResolveResult(authResolver.resolveFromRequest(request, createIfMissing = true))
return resolved.toResponseEntity()
}
@PutMapping("/me")
fun updateMe(
@AuthenticationPrincipal principal: MyPrincipal?,
request: HttpServletRequest,
@RequestBody body: UpdateMeRequest
): ResponseEntity<AuthResponse> {
val resolved = principal?.let { ResolveResult(it) }
?: ResolveResult(authResolver.resolveFromRequest(request, createIfMissing = true))
if (resolved.principal == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(AuthResponse(status = "UNAUTHORIZED"))
}
val user = appUserRepo.findById(resolved.principal.userId).orElseThrow {
ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found")
}
if (!body.name.isNullOrBlank()) {
user.name = body.name.trim()
}
appUserRepo.save(user)
return ResponseEntity.ok(buildAuthResponse(resolved.principal))
}
private fun buildAuthResponse(principal: MyPrincipal): AuthResponse {
val user = appUserRepo.findById(principal.userId).orElseThrow {
ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found")
}
val memberships = propertyUserRepo.findByIdUserId(principal.userId).map {
PropertyUserResponse(
userId = it.id.userId!!,
propertyId = it.id.propertyId!!,
roles = it.roles.map { role -> role.name }.toSet()
)
}
val status = when {
user.superAdmin -> "SUPER_ADMIN"
memberships.isEmpty() -> "NO_PROPERTIES"
else -> "OK"
}
return AuthResponse(
status = status,
user = UserResponse(
id = user.id!!,
firebaseUid = user.firebaseUid,
phoneE164 = user.phoneE164,
name = user.name,
disabled = user.disabled,
superAdmin = user.superAdmin
),
properties = memberships
)
}
private fun ResolveResult.toResponseEntity(): ResponseEntity<AuthResponse> {
return if (principal == null) {
ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(AuthResponse(status = "UNAUTHORIZED"))
} else {
ResponseEntity.ok(buildAuthResponse(principal))
}
}
}
data class AuthResponse(
val status: String,
val user: UserResponse? = null,
val properties: List<PropertyUserResponse> = emptyList()
)
data class UpdateMeRequest(
val name: String? = null
)
private data class ResolveResult(
val principal: MyPrincipal?
)

View File

@@ -1,62 +0,0 @@
package com.android.trisolarisserver.controller.booking
import com.android.trisolarisserver.controller.common.computeExpectedPay
import com.android.trisolarisserver.controller.common.requireMember
import com.android.trisolarisserver.component.auth.PropertyAccess
import com.android.trisolarisserver.controller.dto.payment.BookingBalanceResponse
import com.android.trisolarisserver.repo.booking.BookingRepo
import com.android.trisolarisserver.repo.booking.ChargeRepo
import com.android.trisolarisserver.repo.booking.PaymentRepo
import com.android.trisolarisserver.repo.room.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.RequestMapping
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.server.ResponseStatusException
import java.util.UUID
@RestController
@RequestMapping("/properties/{propertyId}/bookings/{bookingId}/balance")
class BookingBalances(
private val propertyAccess: PropertyAccess,
private val bookingRepo: BookingRepo,
private val roomStayRepo: RoomStayRepo,
private val chargeRepo: ChargeRepo,
private val paymentRepo: PaymentRepo
) {
@GetMapping
@Transactional(readOnly = true)
fun getBalance(
@PathVariable propertyId: UUID,
@PathVariable bookingId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
): BookingBalanceResponse {
requireMember(propertyAccess, propertyId, principal)
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")
}
val expected = computeExpectedPay(
roomStayRepo.findByBookingId(bookingId),
booking.property.timezone,
booking.billingMode,
booking.billingCheckinTime,
booking.billingCheckoutTime
)
val charges = chargeRepo.sumAmountByBookingId(bookingId)
val collected = paymentRepo.sumAmountByBookingId(bookingId)
val pending = expected + charges - collected
return BookingBalanceResponse(
expectedPay = expected + charges,
amountCollected = collected,
pending = pending
)
}
}

View File

@@ -1,161 +0,0 @@
package com.android.trisolarisserver.controller.booking
import com.android.trisolarisserver.component.auth.PropertyAccess
import com.android.trisolarisserver.controller.common.parseOffset
import com.android.trisolarisserver.controller.common.requireRole
import com.android.trisolarisserver.controller.dto.booking.BookingRoomRequestCreateRequest
import com.android.trisolarisserver.controller.dto.booking.BookingRoomRequestResponse
import com.android.trisolarisserver.models.booking.BookingRoomRequest
import com.android.trisolarisserver.models.booking.BookingRoomRequestStatus
import com.android.trisolarisserver.models.booking.BookingStatus
import com.android.trisolarisserver.models.property.Role
import com.android.trisolarisserver.repo.booking.BookingRepo
import com.android.trisolarisserver.repo.booking.BookingRoomRequestRepo
import com.android.trisolarisserver.repo.property.AppUserRepo
import com.android.trisolarisserver.repo.room.RoomRepo
import com.android.trisolarisserver.repo.room.RoomStayRepo
import com.android.trisolarisserver.repo.room.RoomTypeRepo
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.DeleteMapping
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}/bookings/{bookingId}/room-requests")
class BookingRoomRequests(
private val propertyAccess: PropertyAccess,
private val bookingRepo: BookingRepo,
private val roomTypeRepo: RoomTypeRepo,
private val roomRepo: RoomRepo,
private val roomStayRepo: RoomStayRepo,
private val appUserRepo: AppUserRepo,
private val bookingRoomRequestRepo: BookingRoomRequestRepo
) {
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
@Transactional
fun create(
@PathVariable propertyId: UUID,
@PathVariable bookingId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: BookingRoomRequestCreateRequest
): BookingRoomRequestResponse {
val actor = requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER, Role.STAFF)
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")
}
if (booking.status == BookingStatus.CANCELLED || booking.status == BookingStatus.NO_SHOW || booking.status == BookingStatus.CHECKED_OUT) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Booking closed")
}
if (request.quantity <= 0) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "quantity must be > 0")
}
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")
}
val roomType = roomTypeRepo.findByPropertyIdAndCodeIgnoreCase(propertyId, request.roomTypeCode)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room type not found")
val capacity = roomRepo.countActiveSellableByType(propertyId, roomType.id!!)
val occupied = roomStayRepo.countOccupiedByTypeInRange(propertyId, roomType.id!!, fromAt, toAt)
val requested = bookingRoomRequestRepo.sumRemainingByTypeAndRange(propertyId, roomType.id!!, fromAt, toAt)
val free = capacity - occupied - requested
if (request.quantity.toLong() > free) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Insufficient room type availability")
}
val appUser = appUserRepo.findById(actor.userId).orElse(null)
val saved = bookingRoomRequestRepo.save(
BookingRoomRequest(
property = booking.property,
booking = booking,
roomType = roomType,
quantity = request.quantity,
fromAt = fromAt,
toAt = toAt,
status = BookingRoomRequestStatus.ACTIVE,
createdBy = appUser
)
)
return saved.toResponse()
}
@GetMapping
fun list(
@PathVariable propertyId: UUID,
@PathVariable bookingId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
): List<BookingRoomRequestResponse> {
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER, Role.STAFF, Role.HOUSEKEEPING, Role.FINANCE)
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 bookingRoomRequestRepo.findByBookingIdOrderByCreatedAtAsc(bookingId).map { it.toResponse() }
}
@DeleteMapping("/{requestId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
@Transactional
fun cancel(
@PathVariable propertyId: UUID,
@PathVariable bookingId: UUID,
@PathVariable requestId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
) {
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER, Role.STAFF)
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")
}
val request = bookingRoomRequestRepo.findById(requestId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Room request not found")
}
if (request.booking.id != bookingId || request.property.id != propertyId) {
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room request not found for booking")
}
if (request.status == BookingRoomRequestStatus.FULFILLED) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Cannot cancel fulfilled room request")
}
request.status = BookingRoomRequestStatus.CANCELLED
bookingRoomRequestRepo.save(request)
}
private fun BookingRoomRequest.toResponse(): BookingRoomRequestResponse {
val remaining = (quantity - fulfilledQuantity).coerceAtLeast(0)
return BookingRoomRequestResponse(
id = id!!,
bookingId = booking.id!!,
roomTypeCode = roomType.code,
quantity = quantity,
fulfilledQuantity = fulfilledQuantity,
remainingQuantity = remaining,
fromAt = fromAt.toString(),
toAt = toAt.toString(),
status = status.name
)
}
}

View File

@@ -1,145 +0,0 @@
package com.android.trisolarisserver.controller.booking
import com.android.trisolarisserver.controller.common.billableNights
import com.android.trisolarisserver.controller.common.computeExpectedPay
import com.android.trisolarisserver.controller.common.computeExpectedPayTotal
import com.android.trisolarisserver.controller.common.nowForProperty
import com.android.trisolarisserver.controller.dto.booking.BookingDetailResponse
import com.android.trisolarisserver.models.booking.BookingStatus
import com.android.trisolarisserver.repo.booking.BookingRepo
import com.android.trisolarisserver.repo.booking.ChargeRepo
import com.android.trisolarisserver.repo.guest.GuestVehicleRepo
import com.android.trisolarisserver.repo.booking.PaymentRepo
import com.android.trisolarisserver.repo.room.RoomStayRepo
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Component
import org.springframework.web.server.ResponseStatusException
import java.util.UUID
@Component
class BookingSnapshotBuilder(
private val bookingRepo: BookingRepo,
private val roomStayRepo: RoomStayRepo,
private val chargeRepo: ChargeRepo,
private val paymentRepo: PaymentRepo,
private val guestVehicleRepo: GuestVehicleRepo
) {
fun build(propertyId: UUID, bookingId: UUID): BookingDetailResponse {
val booking = bookingRepo.findDetailedById(bookingId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found")
}
if (booking.property.id != propertyId) {
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found for property")
}
val stays = roomStayRepo.findByBookingIdWithRoom(bookingId)
val activeRooms = stays.filter { it.toAt == null }
val roomsToShow = activeRooms.ifEmpty { stays }
val roomNumbers = roomsToShow.map { it.room.roomNumber }
.distinct()
.sorted()
val guest = booking.primaryGuest
val vehicleNumbers = if (guest?.id != null) {
guestVehicleRepo.findByGuestIdIn(listOf(guest.id!!))
.map { it.vehicleNumber }
.distinct()
.sorted()
} else {
emptyList()
}
val signatureUrl = guest?.signaturePath?.let {
"/properties/$propertyId/guests/${guest.id}/signature/file"
}
val totalNightlyRate = roomsToShow.sumOf { it.nightlyRate ?: 0L }
val billableNights = computeBookingBillableNights(booking)
val expectedPay = computeExpectedPayTotal(
stays,
booking.expectedCheckoutAt,
booking.property.timezone,
booking.billingMode,
booking.billingCheckinTime,
booking.billingCheckoutTime
)
val accruedPay = computeExpectedPay(
stays,
booking.property.timezone,
booking.billingMode,
booking.billingCheckinTime,
booking.billingCheckoutTime
)
val extraCharges = chargeRepo.sumAmountByBookingId(bookingId)
val amountCollected = paymentRepo.sumAmountByBookingId(bookingId)
val pending = accruedPay + extraCharges - amountCollected
return BookingDetailResponse(
id = booking.id!!,
status = booking.status.name,
guestId = guest?.id,
guestName = guest?.name,
guestPhone = guest?.phoneE164,
guestNationality = guest?.nationality,
guestAddressText = guest?.addressText,
guestAge = guest?.age,
guestSignatureUrl = signatureUrl,
vehicleNumbers = vehicleNumbers,
roomNumbers = roomNumbers,
source = booking.source,
billingMode = booking.billingMode.name,
billingCheckinTime = booking.billingCheckinTime,
billingCheckoutTime = booking.billingCheckoutTime,
fromCity = booking.fromCity,
toCity = booking.toCity,
memberRelation = booking.memberRelation?.name,
transportMode = booking.transportMode?.name,
checkInAt = booking.checkinAt?.toString(),
checkOutAt = booking.checkoutAt?.toString(),
expectedCheckInAt = booking.expectedCheckinAt?.toString(),
expectedCheckOutAt = booking.expectedCheckoutAt?.toString(),
adultCount = booking.adultCount,
childCount = booking.childCount,
maleCount = booking.maleCount,
femaleCount = booking.femaleCount,
totalGuestCount = booking.totalGuestCount,
expectedGuestCount = booking.expectedGuestCount,
notes = booking.notes,
registeredByName = booking.createdBy?.name,
registeredByPhone = booking.createdBy?.phoneE164,
totalNightlyRate = totalNightlyRate,
billableNights = billableNights,
expectedPay = expectedPay + extraCharges,
amountCollected = amountCollected,
pending = pending
)
}
private fun computeBookingBillableNights(booking: com.android.trisolarisserver.models.booking.Booking): Long? {
val startAt: java.time.OffsetDateTime
val endAt: java.time.OffsetDateTime
when (booking.status) {
BookingStatus.OPEN -> {
startAt = booking.expectedCheckinAt ?: return null
endAt = booking.expectedCheckoutAt ?: return null
}
BookingStatus.CHECKED_IN -> {
startAt = booking.checkinAt ?: booking.expectedCheckinAt ?: return null
endAt = booking.expectedCheckoutAt ?: nowForProperty(booking.property.timezone)
}
BookingStatus.CHECKED_OUT,
BookingStatus.CANCELLED,
BookingStatus.NO_SHOW -> {
startAt = booking.checkinAt ?: booking.expectedCheckinAt ?: return null
endAt = booking.checkoutAt ?: booking.expectedCheckoutAt ?: return null
}
}
return billableNights(
startAt = startAt,
endAt = endAt,
timezone = booking.property.timezone,
billingMode = booking.billingMode,
billingCheckinTime = booking.billingCheckinTime,
billingCheckoutTime = booking.billingCheckoutTime
)
}
}

View File

@@ -1,63 +0,0 @@
package com.android.trisolarisserver.controller.card
import java.time.OffsetDateTime
internal fun encodeBlock(value: String?): String {
val raw = (value ?: "").padEnd(16).take(16)
val bytes = raw.toByteArray(Charsets.UTF_8)
val sb = StringBuilder(bytes.size * 2)
for (b in bytes) {
sb.append(String.format("%02X", b))
}
return sb.toString()
}
internal fun buildSector0Block2(roomNumber: Int, cardID: Int): String {
val guestID = cardID + 1
val cardIdStr = cardID.toString().padStart(6, '0')
val guestIdStr = guestID.toString().padStart(6, '0')
val finalRoom = roomNumber.toString().padStart(2, '0')
return "472F${cardIdStr}2F${guestIdStr}00010000${finalRoom}0000"
}
internal 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}"
}
internal 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
}

View File

@@ -1,19 +0,0 @@
package com.android.trisolarisserver.controller.card
import com.android.trisolarisserver.controller.dto.booking.IssuedCardResponse
import com.android.trisolarisserver.models.room.IssuedCard
internal 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

@@ -1,157 +0,0 @@
package com.android.trisolarisserver.controller.card
import com.android.trisolarisserver.controller.common.nowForProperty
import com.android.trisolarisserver.controller.common.parseOffset
import com.android.trisolarisserver.controller.common.requireMember
import com.android.trisolarisserver.component.auth.PropertyAccess
import com.android.trisolarisserver.controller.dto.booking.CardPrepareResponse
import com.android.trisolarisserver.controller.dto.booking.IssueTempCardRequest
import com.android.trisolarisserver.controller.dto.booking.IssuedCardResponse
import com.android.trisolarisserver.models.property.Role
import com.android.trisolarisserver.models.room.IssuedCard
import com.android.trisolarisserver.repo.property.AppUserRepo
import com.android.trisolarisserver.repo.card.IssuedCardRepo
import com.android.trisolarisserver.repo.card.PropertyCardCounterRepo
import com.android.trisolarisserver.repo.property.PropertyRepo
import com.android.trisolarisserver.repo.room.RoomRepo
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}/rooms/{roomId}/cards")
class TemporaryRoomCards(
private val propertyAccess: PropertyAccess,
private val roomRepo: RoomRepo,
private val issuedCardRepo: IssuedCardRepo,
private val appUserRepo: AppUserRepo,
private val counterRepo: PropertyCardCounterRepo,
private val propertyRepo: PropertyRepo
) {
private val temporaryCardDurationMinutes = 7L
@PostMapping("/prepare-temp")
@ResponseStatus(HttpStatus.CREATED)
@Transactional
fun prepareTemporary(
@PathVariable propertyId: UUID,
@PathVariable roomId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
): CardPrepareResponse {
val actor = requireIssueActor(propertyId, principal)
val room = roomRepo.findByIdAndPropertyId(roomId, propertyId)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room not found")
val issuedAt = nowForProperty(room.property.timezone)
val expiresAt = issuedAt.plusMinutes(temporaryCardDurationMinutes)
val cardIndex = nextCardIndex(propertyId)
val payload = buildSector0Payload(room.roomNumber, cardIndex, issuedAt, expiresAt)
return CardPrepareResponse(
cardIndex = cardIndex,
key = payload.key,
timeData = payload.timeData,
issuedAt = issuedAt.toString(),
expiresAt = expiresAt.toString(),
sector3Block0 = encodeBlock(actor.name),
sector3Block1 = encodeBlock(actor.id?.toString()),
sector3Block2 = encodeBlock(null)
)
}
@PostMapping("/temp")
@ResponseStatus(HttpStatus.CREATED)
@Transactional
fun issueTemporary(
@PathVariable propertyId: UUID,
@PathVariable roomId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: IssueTempCardRequest
): 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 room = roomRepo.findByIdAndPropertyId(roomId, propertyId)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room not found")
val issuedAt = parseOffset(request.issuedAt) ?: nowForProperty(room.property.timezone)
val expiresAt = issuedAt.plusMinutes(temporaryCardDurationMinutes)
val now = OffsetDateTime.now()
if (issuedCardRepo.existsActiveForRoom(propertyId, room.id!!, now)) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Active card already exists for room")
}
val card = IssuedCard(
property = room.property,
room = room,
roomStay = null,
cardId = request.cardId.trim(),
cardIndex = request.cardIndex,
issuedAt = issuedAt,
expiresAt = expiresAt,
issuedBy = actor
)
return issuedCardRepo.save(card).toResponse()
}
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 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 = 10001
))
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
): TempSector0Payload {
val key = buildSector0Block2(roomNumber, cardIndex)
val newData = "0000000000" + formatDateComponents(issuedAt) + formatDateComponents(expiresAt)
val checkSum = calculateChecksum(key + newData)
val finalData = newData + checkSum
return TempSector0Payload(key, finalData)
}
}
private data class TempSector0Payload(
val key: String,
val timeData: String
)

View File

@@ -1,55 +0,0 @@
package com.android.trisolarisserver.controller.common
import com.android.trisolarisserver.controller.common.requireMember
import com.android.trisolarisserver.controller.common.requirePrincipal
import com.android.trisolarisserver.controller.common.requireRole
import com.android.trisolarisserver.controller.common.requireSuperAdmin
import com.android.trisolarisserver.controller.common.requireUser
import com.android.trisolarisserver.component.auth.PropertyAccess
import com.android.trisolarisserver.models.property.AppUser
import com.android.trisolarisserver.models.property.Role
import com.android.trisolarisserver.repo.property.AppUserRepo
import com.android.trisolarisserver.security.MyPrincipal
import org.springframework.http.HttpStatus
import org.springframework.web.server.ResponseStatusException
import java.util.UUID
internal fun requirePrincipal(principal: MyPrincipal?): MyPrincipal {
return principal ?: throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
}
internal fun requireUser(appUserRepo: AppUserRepo, principal: MyPrincipal?): AppUser {
val resolved = requirePrincipal(principal)
return appUserRepo.findById(resolved.userId).orElseThrow {
ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found")
}
}
internal fun requireMember(
propertyAccess: PropertyAccess,
propertyId: UUID,
principal: MyPrincipal?
): MyPrincipal {
val resolved = requirePrincipal(principal)
propertyAccess.requireMember(propertyId, resolved.userId)
return resolved
}
internal fun requireRole(
propertyAccess: PropertyAccess,
propertyId: UUID,
principal: MyPrincipal?,
vararg roles: Role
): MyPrincipal {
val resolved = requireMember(propertyAccess, propertyId, principal)
propertyAccess.requireAnyRole(propertyId, resolved.userId, *roles)
return resolved
}
internal fun requireSuperAdmin(appUserRepo: AppUserRepo, principal: MyPrincipal?): AppUser {
val user = requireUser(appUserRepo, principal)
if (!user.superAdmin) {
throw ResponseStatusException(HttpStatus.FORBIDDEN, "Super admin only")
}
return user
}

View File

@@ -1,218 +0,0 @@
package com.android.trisolarisserver.controller.common
import com.android.trisolarisserver.controller.common.computeExpectedPay
import com.android.trisolarisserver.controller.common.computeExpectedPayTotal
import com.android.trisolarisserver.controller.common.daysBetweenInclusive
import com.android.trisolarisserver.controller.common.nowForProperty
import com.android.trisolarisserver.controller.common.parseDate
import com.android.trisolarisserver.controller.common.parseOffset
import com.android.trisolarisserver.controller.common.requireOpenRoomStayForProperty
import com.android.trisolarisserver.controller.common.requireProperty
import com.android.trisolarisserver.controller.common.requirePropertyGuest
import com.android.trisolarisserver.controller.common.requireRoomStayForProperty
import com.android.trisolarisserver.repo.guest.GuestRepo
import com.android.trisolarisserver.models.booking.Guest
import com.android.trisolarisserver.models.booking.BookingBillingMode
import com.android.trisolarisserver.models.property.Property
import com.android.trisolarisserver.models.room.RoomStay
import com.android.trisolarisserver.repo.property.PropertyRepo
import com.android.trisolarisserver.repo.room.RoomStayRepo
import org.springframework.http.HttpStatus
import org.springframework.web.server.ResponseStatusException
import java.time.LocalDate
import java.time.LocalTime
import java.time.OffsetDateTime
import java.time.ZoneId
import java.util.UUID
internal data class PropertyGuest(
val property: Property,
val guest: Guest
)
internal fun requireProperty(propertyRepo: PropertyRepo, propertyId: UUID): Property {
return propertyRepo.findById(propertyId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
}
}
internal fun requirePropertyGuest(
propertyRepo: PropertyRepo,
guestRepo: GuestRepo,
propertyId: UUID,
guestId: UUID
): PropertyGuest {
val property = requireProperty(propertyRepo, propertyId)
val guest = guestRepo.findById(guestId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Guest not found")
}
if (guest.property.id != property.id) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Guest not in property")
}
return PropertyGuest(property, guest)
}
internal fun requireRoomStayForProperty(
roomStayRepo: RoomStayRepo,
propertyId: UUID,
roomStayId: UUID
): RoomStay {
val stay = roomStayRepo.findById(roomStayId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Room stay not found")
}
if (stay.property.id != propertyId || stay.isVoided) {
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room stay not found for property")
}
return stay
}
internal fun requireOpenRoomStayForProperty(
roomStayRepo: RoomStayRepo,
propertyId: UUID,
roomStayId: UUID,
closedMessage: String
): RoomStay {
val stay = requireRoomStayForProperty(roomStayRepo, propertyId, roomStayId)
if (stay.toAt != null) {
throw ResponseStatusException(HttpStatus.CONFLICT, closedMessage)
}
return stay
}
internal 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")
}
}
internal fun parseDate(value: String, errorMessage: String): LocalDate {
return try {
LocalDate.parse(value.trim())
} catch (_: Exception) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, errorMessage)
}
}
internal fun nowForProperty(timezone: String?): OffsetDateTime {
val zone = try {
if (timezone.isNullOrBlank()) ZoneId.of("Asia/Kolkata") else ZoneId.of(timezone)
} catch (_: Exception) {
ZoneId.of("Asia/Kolkata")
}
return OffsetDateTime.now(zone)
}
internal fun computeExpectedPay(
stays: List<RoomStay>,
timezone: String?,
billingMode: BookingBillingMode,
billingCheckinTime: String?,
billingCheckoutTime: String?
): Long {
if (stays.isEmpty()) return 0
val now = nowForProperty(timezone)
var total = 0L
stays.forEach { stay ->
if (stay.isVoided) return@forEach
val rate = stay.nightlyRate ?: 0L
if (rate == 0L) return@forEach
val endAt = stay.toAt ?: now
val nights = billableNights(
stay.fromAt,
endAt,
timezone,
billingMode,
billingCheckinTime,
billingCheckoutTime
)
total += rate * nights
}
return total
}
internal fun computeExpectedPayTotal(
stays: List<RoomStay>,
expectedCheckoutAt: OffsetDateTime?,
timezone: String?,
billingMode: BookingBillingMode,
billingCheckinTime: String?,
billingCheckoutTime: String?
): Long {
if (stays.isEmpty()) return 0
val now = nowForProperty(timezone)
var total = 0L
stays.forEach { stay ->
if (stay.isVoided) return@forEach
val rate = stay.nightlyRate ?: 0L
if (rate == 0L) return@forEach
val endAt = stay.toAt ?: expectedCheckoutAt ?: now
val nights = billableNights(
stay.fromAt,
endAt,
timezone,
billingMode,
billingCheckinTime,
billingCheckoutTime
)
total += rate * nights
}
return total
}
private const val DEFAULT_BILLING_CHECKIN_TIME = "12:00"
private const val DEFAULT_BILLING_CHECKOUT_TIME = "11:00"
internal fun billableNights(
startAt: OffsetDateTime,
endAt: OffsetDateTime,
timezone: String?,
billingMode: BookingBillingMode,
billingCheckinTime: String?,
billingCheckoutTime: String?
): Long {
if (!endAt.isAfter(startAt)) return 1L
if (billingMode == BookingBillingMode.FULL_24H) {
val minutes = java.time.Duration.between(startAt, endAt).toMinutes().coerceAtLeast(0L)
if (minutes == 0L) return 1L
val fullDays = minutes / (24L * 60L)
val remainder = minutes % (24L * 60L)
val extraNight = if (remainder > 120L) 1L else 0L
return (fullDays + extraNight).coerceAtLeast(1L)
}
val zone = try {
if (timezone.isNullOrBlank()) ZoneId.of("Asia/Kolkata") else ZoneId.of(timezone)
} catch (_: Exception) {
ZoneId.of("Asia/Kolkata")
}
val checkinPolicy = parseBillingTimeOrDefault(billingCheckinTime, DEFAULT_BILLING_CHECKIN_TIME)
val checkoutPolicy = parseBillingTimeOrDefault(billingCheckoutTime, DEFAULT_BILLING_CHECKOUT_TIME)
val localStart = startAt.atZoneSameInstant(zone)
val localEnd = endAt.atZoneSameInstant(zone)
var billStartDate = localStart.toLocalDate()
if (localStart.toLocalTime().isBefore(checkinPolicy)) {
billStartDate = billStartDate.minusDays(1)
}
var billEndDate = localEnd.toLocalDate()
if (localEnd.toLocalTime().isAfter(checkoutPolicy)) {
billEndDate = billEndDate.plusDays(1)
}
val diff = billEndDate.toEpochDay() - billStartDate.toEpochDay()
return if (diff <= 0L) 1L else diff
}
private fun parseBillingTimeOrDefault(raw: String?, fallback: String): LocalTime {
val value = raw?.trim()?.takeIf { it.isNotEmpty() } ?: fallback
return try {
LocalTime.parse(value)
} catch (_: Exception) {
LocalTime.parse(fallback)
}
}
internal fun daysBetweenInclusive(start: LocalDate, end: LocalDate): Long {
val diff = end.toEpochDay() - start.toEpochDay()
return if (diff <= 0) 1L else diff
}

View File

@@ -1,13 +0,0 @@
package com.android.trisolarisserver.controller.document
object DocumentPrompts {
val NAME = "name" to "NAME? Reply only the name or NONE."
val DOB = "dob" to "DOB? Reply only date or NONE."
val ID_NUMBER = "idNumber" to "ID NUMBER?. Reply only number or NONE."
val ADDRESS = "address" to "POSTAL ADDRESS ONLY (street/area/city/state). Ignore IDs, UUIDs, and codes. Reply only address or NONE."
val PIN_CODE = "pinCode" to "CITY POSTAL PIN CODE. Reply only pin or NONE."
val CITY = "city" to "CITY? Reply only city or NONE."
val GENDER = "gender" to "GENDER? Reply only MALE/FEMALE/OTHER or NONE."
val NATIONALITY = "nationality" to "NATIONALITY? Reply only nationality or NONE."
val VEHICLE_NUMBER = "vehicleNumber" to "VEHICLE NUMBER? Reply only number or NONE."
}

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

@@ -1,4 +1,4 @@
package com.android.trisolarisserver.controller.dto.guest
package com.android.trisolarisserver.controller.dto
import java.util.UUID
@@ -10,6 +10,7 @@ data class GuestRatingCreateRequest(
data class GuestRatingResponse(
val id: UUID,
val orgId: UUID,
val propertyId: UUID,
val guestId: UUID,
val bookingId: UUID,

View File

@@ -1,14 +1,26 @@
package com.android.trisolarisserver.controller.dto.property
package com.android.trisolarisserver.controller.dto
import java.util.UUID
data class OrgCreateRequest(
val name: String,
val emailAliases: Set<String>? = null,
val allowedTransportModes: Set<String>? = null
)
data class OrgResponse(
val id: UUID,
val name: String,
val emailAliases: Set<String>,
val allowedTransportModes: Set<String>
)
data class PropertyCreateRequest(
val code: String,
val name: String,
val addressText: String? = null,
val timezone: String? = null,
val currency: String? = null,
val billingCheckinTime: String? = null,
val billingCheckoutTime: String? = null,
val active: Boolean? = null,
val otaAliases: Set<String>? = null,
val emailAddresses: Set<String>? = null,
@@ -21,8 +33,6 @@ data class PropertyUpdateRequest(
val addressText: String? = null,
val timezone: String? = null,
val currency: String? = null,
val billingCheckinTime: String? = null,
val billingCheckoutTime: String? = null,
val active: Boolean? = null,
val otaAliases: Set<String>? = null,
val emailAddresses: Set<String>? = null,
@@ -31,61 +41,31 @@ data class PropertyUpdateRequest(
data class PropertyResponse(
val id: UUID,
val orgId: UUID,
val code: String,
val name: String,
val addressText: String?,
val timezone: String,
val currency: String,
val billingCheckinTime: String,
val billingCheckoutTime: String,
val active: Boolean,
val otaAliases: Set<String>,
val emailAddresses: Set<String>,
val allowedTransportModes: Set<String>
)
data class PropertyCodeResponse(
val code: String
)
data class PropertyBillingPolicyRequest(
val billingCheckinTime: String,
val billingCheckoutTime: String
)
data class PropertyBillingPolicyResponse(
val propertyId: UUID,
val billingCheckinTime: String,
val billingCheckoutTime: String
)
data class GuestResponse(
val id: UUID,
val orgId: UUID,
val name: String?,
val phoneE164: String?,
val dob: String?,
val nationality: String?,
val addressText: String?,
val signatureUrl: String?,
val vehicleNumbers: Set<String>,
val averageScore: Double?
)
data class GuestUpdateRequest(
val phoneE164: String? = null,
val name: String? = null,
val nationality: String? = null,
val addressText: String? = null
)
data class GuestVehicleRequest(
val vehicleNumber: String,
val bookingId: UUID
)
data class GuestVisitCountResponse(
val guestId: UUID?,
val bookingCount: Long
val vehicleNumber: String
)
data class TransportModeStatusResponse(
@@ -95,21 +75,17 @@ data class TransportModeStatusResponse(
data class UserResponse(
val id: UUID,
val orgId: UUID,
val firebaseUid: String?,
val phoneE164: String?,
val name: String?,
val disabled: Boolean,
val superAdmin: Boolean
val disabled: Boolean
)
data class PropertyUserRoleRequest(
val roles: Set<String>
)
data class PropertyUserDisableRequest(
val disabled: Boolean
)
data class PropertyUserResponse(
val userId: UUID,
val propertyId: UUID,

View File

@@ -1,4 +1,4 @@
package com.android.trisolarisserver.controller.dto.room
package com.android.trisolarisserver.controller.dto
import java.util.UUID
@@ -6,13 +6,12 @@ data class RoomResponse(
val id: UUID,
val roomNumber: Int,
val floor: Int?,
val roomTypeId: UUID,
val roomTypeName: String,
val hasNfc: Boolean,
val active: Boolean,
val maintenance: Boolean,
val notes: String?,
val tempCardActive: Boolean = false,
val tempCardExpiresAt: String? = null
val notes: String?
)
data class RoomBoardResponse(
@@ -27,27 +26,19 @@ data class RoomAvailabilityResponse(
)
data class RoomAvailabilityRangeResponse(
val roomTypeCode: String,
val roomTypeName: String,
val freeRoomNumbers: List<Int>,
val freeCount: Int,
val averageRate: Double?,
val currency: String,
val ratePlanCode: String? = null
val freeCount: Int
)
data class RoomImageResponse(
val id: UUID,
val propertyId: UUID,
val roomId: UUID,
val roomTypeCode: String?,
val url: String,
val thumbnailUrl: String,
val contentType: String,
val sizeBytes: Long,
val tags: Set<RoomImageTagResponse>,
val roomSortOrder: Int,
val roomTypeSortOrder: Int,
val createdAt: String
)
@@ -61,26 +52,9 @@ enum class RoomBoardStatus {
data class RoomUpsertRequest(
val roomNumber: Int,
val floor: Int?,
val roomTypeCode: String,
val roomTypeId: UUID,
val hasNfc: Boolean,
val active: Boolean,
val maintenance: Boolean,
val notes: String?
)
data class RoomImageReorderRequest(
val imageIds: List<UUID>
)
data class RoomImageTagUpsertRequest(
val name: String
)
data class RoomImageTagResponse(
val id: UUID,
val name: String
)
data class RoomImageTagUpdateRequest(
val tagIds: Set<UUID>
)

View File

@@ -0,0 +1,21 @@
package com.android.trisolarisserver.controller.dto
import java.util.UUID
data class RoomTypeUpsertRequest(
val code: String,
val name: String,
val baseOccupancy: Int? = null,
val maxOccupancy: Int? = null,
val otaAliases: Set<String>? = null
)
data class RoomTypeResponse(
val id: UUID,
val propertyId: UUID,
val code: String,
val name: String,
val baseOccupancy: Int,
val maxOccupancy: Int,
val otaAliases: Set<String>
)

View File

@@ -1,222 +0,0 @@
package com.android.trisolarisserver.controller.dto.booking
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import java.util.UUID
@JsonIgnoreProperties(ignoreUnknown = false)
data class BookingCheckInStayRequest(
val roomId: UUID,
val checkInAt: String? = null,
val nightlyRate: Long? = null,
val rateSource: String? = null,
val ratePlanCode: String? = null,
val currency: String? = null
)
@JsonIgnoreProperties(ignoreUnknown = false)
data class BookingBulkCheckInRequest(
val stays: List<BookingCheckInStayRequest>,
val transportMode: String? = null,
val notes: String? = null
)
data class BookingCreateRequest(
val source: String? = null,
val expectedCheckInAt: String,
val expectedCheckOutAt: String,
val billingMode: String? = null,
val billingCheckoutTime: String? = null,
val guestPhoneE164: String? = null,
val fromCity: String? = null,
val toCity: String? = null,
val memberRelation: String? = null,
val transportMode: String? = null,
val childCount: Int? = null,
val maleCount: Int? = null,
val femaleCount: Int? = null,
val expectedGuestCount: Int? = null,
val notes: String? = null
)
data class BookingCreateResponse(
val id: UUID,
val status: String,
val billingMode: String,
val billingCheckinTime: String,
val billingCheckoutTime: String,
val guestId: UUID?,
val checkInAt: String?,
val expectedCheckInAt: String?,
val expectedCheckOutAt: String?
)
data class BookingListItem(
val id: UUID,
val status: String,
val guestId: UUID?,
val guestName: String?,
val guestPhone: String?,
val vehicleNumbers: List<String>,
val roomNumbers: List<Int>,
val source: String?,
val billingMode: String,
val billingCheckinTime: String,
val billingCheckoutTime: String,
val expectedCheckInAt: String?,
val expectedCheckOutAt: String?,
val checkInAt: String?,
val checkOutAt: String?,
val adultCount: Int?,
val childCount: Int?,
val maleCount: Int?,
val femaleCount: Int?,
val totalGuestCount: Int?,
val expectedGuestCount: Int?,
val notes: String?,
val pending: Long? = null
)
data class BookingDetailResponse(
val id: UUID,
val status: String,
val guestId: UUID?,
val guestName: String?,
val guestPhone: String?,
val guestNationality: String?,
val guestAddressText: String?,
val guestAge: String?,
val guestSignatureUrl: String?,
val vehicleNumbers: List<String>,
val roomNumbers: List<Int>,
val source: String?,
val billingMode: String,
val billingCheckinTime: String,
val billingCheckoutTime: String,
val fromCity: String?,
val toCity: String?,
val memberRelation: String?,
val transportMode: String?,
val checkInAt: String?,
val checkOutAt: String?,
val expectedCheckInAt: String?,
val expectedCheckOutAt: String?,
val adultCount: Int?,
val childCount: Int?,
val maleCount: Int?,
val femaleCount: Int?,
val totalGuestCount: Int?,
val expectedGuestCount: Int?,
val notes: String?,
val registeredByName: String?,
val registeredByPhone: String?,
val totalNightlyRate: Long,
val billableNights: Long?,
val expectedPay: Long,
val amountCollected: Long,
val pending: Long
)
data class BookingLinkGuestRequest(
val guestId: UUID
)
data class BookingExpectedDatesUpdateRequest(
val expectedCheckInAt: String? = null,
val expectedCheckOutAt: String? = null
)
data class BookingBillingPolicyUpdateRequest(
val billingMode: String,
val billingCheckoutTime: String? = null
)
data class BookingCheckOutRequest(
val checkOutAt: String? = null,
val notes: String? = null
)
data class BookingBillableNightsRequest(
val expectedCheckInAt: String? = null,
val expectedCheckOutAt: String? = null
)
data class BookingBillableNightsResponse(
val bookingId: UUID,
val status: String,
val billableNights: Long
)
data class BookingExpectedCheckoutPreviewRequest(
val checkInAt: String,
val billableNights: Long? = null,
val billingMode: String? = null,
val billingCheckinTime: String? = null,
val billingCheckoutTime: String? = null
)
data class BookingExpectedCheckoutPreviewResponse(
val expectedCheckOutAt: String,
val billableNights: Long,
val billingMode: String,
val billingCheckinTime: String,
val billingCheckoutTime: String
)
data class BookingCancelRequest(
val cancelledAt: String? = null,
val reason: String? = null
)
data class BookingNoShowRequest(
val noShowAt: String? = null,
val reason: String? = null
)
data class RoomStayVoidRequest(
val reason: String? = null
)
data class IssueCardRequest(
val cardId: String,
val cardIndex: Int,
val issuedAt: String? = null,
val expiresAt: String
)
data class IssueTempCardRequest(
val cardId: String,
val cardIndex: Int,
val issuedAt: String? = null
)
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,
val sector3Block0: String? = null,
val sector3Block1: String? = null,
val sector3Block2: String? = null
)
data class CardRevokeResponse(
val timeData: String
)

View File

@@ -1,22 +0,0 @@
package com.android.trisolarisserver.controller.dto.booking
import java.util.UUID
data class BookingRoomRequestCreateRequest(
val roomTypeCode: String,
val quantity: Int,
val fromAt: String,
val toAt: String
)
data class BookingRoomRequestResponse(
val id: UUID,
val bookingId: UUID,
val roomTypeCode: String,
val quantity: Int,
val fulfilledQuantity: Int,
val remainingQuantity: Int,
val fromAt: String,
val toAt: String,
val status: String
)

View File

@@ -1,22 +0,0 @@
package com.android.trisolarisserver.controller.dto.payment
import java.time.OffsetDateTime
import java.util.UUID
data class ChargeCreateRequest(
val type: String,
val amount: Long,
val currency: String,
val occurredAt: String? = null,
val notes: String? = null
)
data class ChargeResponse(
val id: UUID,
val bookingId: UUID,
val type: String,
val amount: Long,
val currency: String,
val occurredAt: OffsetDateTime,
val notes: String?
)

View File

@@ -1,38 +0,0 @@
package com.android.trisolarisserver.controller.dto.payment
import java.util.UUID
data class PaymentCreateRequest(
val amount: Long,
val method: String? = null,
val currency: String? = null,
val reference: String? = null,
val notes: String? = null,
val receivedAt: String? = null
)
data class PaymentResponse(
val id: UUID,
val bookingId: UUID,
val amount: Long,
val currency: String,
val method: String,
val gatewayPaymentId: String?,
val gatewayTxnId: String?,
val bankRefNum: String?,
val mode: String?,
val pgType: String?,
val payerVpa: String?,
val payerName: String?,
val paymentSource: String?,
val reference: String?,
val notes: String?,
val receivedAt: String,
val receivedByUserId: UUID?
)
data class BookingBalanceResponse(
val expectedPay: Long,
val amountCollected: Long,
val pending: Long
)

View File

@@ -1,11 +0,0 @@
package com.android.trisolarisserver.controller.dto.property
data class CancellationPolicyUpsertRequest(
val freeDaysBeforeCheckin: Int = 0,
val penaltyMode: String
)
data class CancellationPolicyResponse(
val freeDaysBeforeCheckin: Int,
val penaltyMode: String
)

View File

@@ -1,39 +0,0 @@
package com.android.trisolarisserver.controller.dto.property
import java.time.OffsetDateTime
import java.util.UUID
data class AppUserSummaryResponse(
val id: UUID,
val phoneE164: String?,
val name: String?,
val disabled: Boolean,
val superAdmin: Boolean
)
data class PropertyUserDetailsResponse(
val userId: UUID,
val propertyId: UUID,
val roles: Set<String>,
val name: String?,
val phoneE164: String?,
val disabled: Boolean,
val superAdmin: Boolean
)
data class PropertyAccessCodeCreateRequest(
val roles: Set<String>
)
data class PropertyAccessCodeResponse(
val propertyId: UUID,
val code: String,
val expiresAt: OffsetDateTime,
val roles: Set<String>
)
data class PropertyAccessCodeJoinRequest(
val propertyCode: String? = null,
val propertyId: String? = null,
val code: String
)

View File

@@ -1,73 +0,0 @@
package com.android.trisolarisserver.controller.dto.rate
import java.time.LocalDate
import java.util.UUID
data class RatePlanCreateRequest(
val code: String,
val name: String,
val roomTypeCode: String,
val baseRate: Long,
val currency: String? = null
)
data class RatePlanUpdateRequest(
val name: String,
val baseRate: Long,
val currency: String? = null
)
data class RatePlanResponse(
val id: UUID,
val propertyId: UUID,
val roomTypeId: UUID,
val roomTypeCode: String,
val code: String,
val name: String,
val baseRate: Long,
val currency: String
)
data class RateCalendarRangeUpsertRequest(
val from: String,
val to: String,
val rate: Long
)
data class RateCalendarResponse(
val id: UUID,
val ratePlanId: UUID,
val rateDate: LocalDate,
val rate: Long
)
data class RateCalendarAverageResponse(
val ratePlanId: UUID,
val from: LocalDate,
val to: LocalDate,
val averageRate: Double,
val days: Int,
val currency: String
)
data class RoomStayRateChangeRequest(
val effectiveAt: String,
val nightlyRate: Long,
val rateSource: String,
val ratePlanCode: String? = null,
val currency: String? = null
)
data class RoomStayRateChangeResponse(
val oldRoomStayId: UUID,
val newRoomStayId: UUID,
val effectiveAt: String
)
data class RateResolveResponse(
val roomTypeCode: String,
val rateDate: LocalDate,
val rate: Long,
val currency: String,
val ratePlanCode: String? = null
)

View File

@@ -1,121 +0,0 @@
package com.android.trisolarisserver.controller.dto.razorpay
import java.util.UUID
data class RazorpaySettingsUpsertRequest(
val keyId: String? = null,
val keySecret: String? = null,
val webhookSecret: String? = null,
val keyIdTest: String? = null,
val keySecretTest: String? = null,
val webhookSecretTest: String? = null,
val isTest: Boolean? = null,
// Backward-compatible aliases (older clients sending PayU-shaped payloads)
val merchantKey: String? = null,
val salt32: String? = null,
val salt256: String? = null,
val useSalt256: Boolean? = null
)
data class RazorpaySettingsResponse(
val propertyId: UUID,
val configured: Boolean,
val isTest: Boolean,
val hasKeyId: Boolean,
val hasKeySecret: Boolean,
val hasWebhookSecret: Boolean,
val hasKeyIdTest: Boolean,
val hasKeySecretTest: Boolean,
val hasWebhookSecretTest: Boolean
)
data class RazorpayQrGenerateRequest(
val amount: Long? = null,
val customerName: String? = null,
val customerEmail: String? = null,
val customerPhone: String? = null,
val expiryMinutes: Int? = null,
val expirySeconds: Int? = null
)
data class RazorpayQrGenerateResponse(
val qrId: String?,
val amount: Long,
val currency: String,
val imageUrl: String?
)
data class RazorpayPaymentLinkCreateRequest(
val amount: Long? = null,
val isPartialPaymentAllowed: Boolean? = null,
val minAmountForCustomer: Long? = null,
val description: String? = null,
val expiryDate: String? = null,
val successUrl: String? = null,
val failureUrl: String? = null,
val viaEmail: Boolean? = null,
val viaSms: Boolean? = null
)
data class RazorpayPaymentLinkCreateResponse(
val amount: Long,
val currency: String,
val paymentLink: String?
)
data class RazorpayQrEventResponse(
val event: String?,
val qrId: String?,
val status: String?,
val receivedAt: String
)
data class RazorpayQrRecordResponse(
val qrId: String?,
val amount: Long,
val currency: String,
val status: String,
val imageUrl: String?,
val expiryAt: String?,
val createdAt: String,
val responsePayload: String?
)
data class RazorpayPaymentRequestResponse(
val type: String,
val requestId: UUID,
val amount: Long,
val currency: String,
val status: String,
val createdAt: String,
val qrId: String? = null,
val imageUrl: String? = null,
val expiryAt: String? = null,
val paymentLinkId: String? = null,
val paymentLink: String? = null
)
data class RazorpayPaymentRequestCloseRequest(
val qrId: String? = null,
val paymentLinkId: String? = null
)
data class RazorpayPaymentRequestCloseResponse(
val type: String,
val qrId: String? = null,
val paymentLinkId: String? = null,
val status: String? = null
)
data class RazorpayRefundRequest(
val paymentId: UUID? = null,
val amount: Long? = null,
val notes: String? = null
)
data class RazorpayRefundResponse(
val refundId: String?,
val status: String?,
val amount: Long?,
val currency: String?
)

View File

@@ -1,18 +0,0 @@
package com.android.trisolarisserver.controller.dto.room
import java.util.UUID
data class ActiveRoomStayResponse(
val roomStayId: UUID,
val bookingId: UUID,
val guestId: UUID?,
val guestName: String?,
val guestPhone: String?,
val roomId: UUID,
val roomNumber: Int,
val roomTypeName: String,
val fromAt: String,
val checkinAt: String?,
val expectedCheckoutAt: String?,
val nightlyRate: Long?
)

View File

@@ -1,44 +0,0 @@
package com.android.trisolarisserver.controller.dto.room
import java.util.UUID
data class RoomTypeUpsertRequest(
val code: String,
val name: String,
val baseOccupancy: Int? = null,
val maxOccupancy: Int? = null,
val sqFeet: Int? = null,
val bathroomSqFeet: Int? = null,
val defaultRate: Long? = null,
val active: Boolean? = null,
val otaAliases: Set<String>? = null,
val amenityIds: Set<UUID>? = null
)
data class RoomTypeResponse(
val id: UUID,
val propertyId: UUID,
val code: String,
val name: String,
val baseOccupancy: Int,
val maxOccupancy: Int,
val sqFeet: Int?,
val bathroomSqFeet: Int?,
val defaultRate: Long?,
val active: Boolean,
val otaAliases: Set<String>,
val amenities: Set<AmenityResponse>
)
data class AmenityUpsertRequest(
val name: String,
val category: String? = null,
val iconKey: String? = null
)
data class AmenityResponse(
val id: UUID,
val name: String,
val category: String?,
val iconKey: String?
)

View File

@@ -1,46 +0,0 @@
package com.android.trisolarisserver.controller.guest
import com.android.trisolarisserver.models.booking.GuestDocument
import com.fasterxml.jackson.databind.ObjectMapper
import java.util.UUID
data class GuestDocumentResponse(
val id: UUID,
val propertyId: UUID,
val guestId: UUID,
val bookingId: UUID,
val uploadedByUserId: UUID,
val uploadedAt: String,
val originalFilename: String,
val contentType: String?,
val sizeBytes: Long,
val extractedData: Map<String, String>?,
val extractedAt: String?
)
fun GuestDocument.toResponse(objectMapper: ObjectMapper): GuestDocumentResponse {
val id = id ?: throw IllegalStateException("Document id missing")
val extracted: Map<String, String>? = extractedData?.let {
try {
val raw = objectMapper.readValue(it, Map::class.java)
raw.entries.associate { entry ->
entry.key.toString() to (entry.value?.toString() ?: "")
}
} catch (_: Exception) {
null
}
}
return GuestDocumentResponse(
id = id,
propertyId = property.id!!,
guestId = guest.id!!,
bookingId = booking.id!!,
uploadedByUserId = uploadedBy.id!!,
uploadedAt = uploadedAt.toString(),
originalFilename = originalFilename,
contentType = contentType,
sizeBytes = sizeBytes,
extractedData = extracted,
extractedAt = extractedAt?.toString()
)
}

View File

@@ -1,262 +0,0 @@
package com.android.trisolarisserver.controller.guest
import com.android.trisolarisserver.controller.common.requireMember
import com.android.trisolarisserver.controller.common.requireProperty
import com.android.trisolarisserver.controller.common.requirePropertyGuest
import com.android.trisolarisserver.controller.common.requireRole
import com.android.trisolarisserver.controller.common.requireUser
import com.android.trisolarisserver.component.storage.DocumentStorage
import com.android.trisolarisserver.component.document.DocumentTokenService
import com.android.trisolarisserver.component.ai.ExtractionQueue
import com.android.trisolarisserver.component.document.GuestDocumentEvents
import com.android.trisolarisserver.component.document.DocumentExtractionService
import com.android.trisolarisserver.component.auth.PropertyAccess
import com.android.trisolarisserver.repo.booking.BookingRepo
import com.android.trisolarisserver.repo.guest.GuestDocumentRepo
import com.android.trisolarisserver.repo.guest.GuestRepo
import com.android.trisolarisserver.models.booking.BookingStatus
import com.android.trisolarisserver.models.booking.GuestDocument
import com.android.trisolarisserver.models.property.Role
import com.android.trisolarisserver.repo.property.AppUserRepo
import com.android.trisolarisserver.repo.property.PropertyRepo
import com.android.trisolarisserver.security.MyPrincipal
import com.fasterxml.jackson.databind.ObjectMapper
import org.springframework.core.io.FileSystemResource
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.bind.annotation.*
import org.springframework.http.MediaType
import jakarta.servlet.http.HttpServletResponse
import org.springframework.web.multipart.MultipartFile
import org.springframework.web.server.ResponseStatusException
import java.time.OffsetDateTime
import java.nio.file.Files
import java.nio.file.Paths
import java.util.UUID
import java.security.MessageDigest
import org.slf4j.LoggerFactory
@RestController
@RequestMapping("/properties/{propertyId}/guests/{guestId}/documents")
class GuestDocuments(
private val propertyAccess: PropertyAccess,
private val propertyRepo: PropertyRepo,
private val guestRepo: GuestRepo,
private val bookingRepo: BookingRepo,
private val guestDocumentRepo: GuestDocumentRepo,
private val appUserRepo: AppUserRepo,
private val storage: DocumentStorage,
private val tokenService: DocumentTokenService,
private val extractionQueue: ExtractionQueue,
private val guestDocumentEvents: GuestDocumentEvents,
private val extractionService: DocumentExtractionService,
private val objectMapper: ObjectMapper,
@org.springframework.beans.factory.annotation.Value("\${storage.documents.publicBaseUrl}")
private val publicBaseUrl: String,
@org.springframework.beans.factory.annotation.Value("\${storage.documents.aiBaseUrl:\${storage.documents.publicBaseUrl}}")
private val aiBaseUrl: String
) {
private val logger = LoggerFactory.getLogger(GuestDocuments::class.java)
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
fun uploadDocument(
@PathVariable propertyId: UUID,
@PathVariable guestId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestParam("bookingId") bookingId: UUID,
@RequestPart("file") file: MultipartFile
): GuestDocumentResponse {
val user = requireUser(appUserRepo, principal)
propertyAccess.requireMember(propertyId, user.id!!)
propertyAccess.requireAnyRole(propertyId, user.id!!, Role.ADMIN, Role.MANAGER)
if (file.isEmpty) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "File is empty")
}
val contentType = file.contentType
if (contentType != null && contentType.startsWith("video/")) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Video files are not allowed")
}
val (property, guest) = requirePropertyGuest(propertyRepo, guestRepo, propertyId, guestId)
val booking = bookingRepo.findById(bookingId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found")
}
if (booking.property.id != propertyId) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Booking not in property")
}
if (booking.primaryGuest?.id != guestId) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Booking not linked to guest")
}
val stored = storage.store(propertyId, guestId, bookingId, file)
val fileHash = hashFile(stored.storagePath)
if (fileHash != null && guestDocumentRepo.existsByPropertyIdAndGuestIdAndBookingIdAndFileHash(
propertyId,
guestId,
bookingId,
fileHash
)
) {
Files.deleteIfExists(Paths.get(stored.storagePath))
throw ResponseStatusException(HttpStatus.CONFLICT, "Duplicate document")
}
val document = GuestDocument(
property = property,
guest = guest,
booking = booking,
uploadedBy = user,
originalFilename = stored.originalFilename,
contentType = stored.contentType,
sizeBytes = stored.sizeBytes,
storagePath = stored.storagePath,
fileHash = fileHash
)
val saved = guestDocumentRepo.save(document)
runExtraction(saved.id!!, propertyId, guestId)
guestDocumentEvents.emit(propertyId, guestId)
return saved.toResponse(objectMapper)
}
@GetMapping
fun listDocuments(
@PathVariable propertyId: UUID,
@PathVariable guestId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
): List<GuestDocumentResponse> {
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER)
return guestDocumentRepo
.findByPropertyIdAndGuestIdOrderByUploadedAtDesc(propertyId, guestId)
.map { it.toResponse(objectMapper) }
}
@GetMapping("/stream", produces = [MediaType.TEXT_EVENT_STREAM_VALUE])
fun streamDocuments(
@PathVariable propertyId: UUID,
@PathVariable guestId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
response: HttpServletResponse
): org.springframework.web.servlet.mvc.method.annotation.SseEmitter {
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER)
response.setHeader("Cache-Control", "no-cache")
response.setHeader("Connection", "keep-alive")
response.setHeader("X-Accel-Buffering", "no")
return guestDocumentEvents.subscribe(propertyId, guestId)
}
@GetMapping("/{documentId}/file")
fun downloadDocument(
@PathVariable propertyId: UUID,
@PathVariable guestId: UUID,
@PathVariable documentId: UUID,
@RequestParam(required = false) token: String?,
@AuthenticationPrincipal principal: MyPrincipal?
): ResponseEntity<FileSystemResource> {
if (token == null) {
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER)
} else if (!tokenService.validateToken(token, documentId.toString())) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid token")
}
val document = guestDocumentRepo.findByIdAndPropertyIdAndGuestId(documentId, propertyId, guestId)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Document not found")
val path = Paths.get(document.storagePath)
if (!Files.exists(path)) {
throw ResponseStatusException(HttpStatus.NOT_FOUND, "File missing")
}
val resource = FileSystemResource(path)
val type = document.contentType ?: MediaType.APPLICATION_OCTET_STREAM_VALUE
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(type))
.header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"${document.originalFilename}\"")
.contentLength(document.sizeBytes)
.body(resource)
}
@DeleteMapping("/{documentId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
@Transactional
fun deleteDocument(
@PathVariable propertyId: UUID,
@PathVariable guestId: UUID,
@PathVariable documentId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
) {
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER)
val document = guestDocumentRepo.findByIdAndPropertyIdAndGuestId(documentId, propertyId, guestId)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Document not found")
val status = document.booking.status
val linkedBookingOpenOrCheckedIn = status == BookingStatus.OPEN || status == BookingStatus.CHECKED_IN
val guestHasOpenOrCheckedInBooking = bookingRepo.existsByPropertyIdAndPrimaryGuestIdAndStatusIn(
propertyId = propertyId,
primaryGuestId = guestId,
status = listOf(BookingStatus.OPEN, BookingStatus.CHECKED_IN)
)
if (!linkedBookingOpenOrCheckedIn && !guestHasOpenOrCheckedInBooking) {
throw ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Documents can only be deleted for OPEN or CHECKED_IN bookings"
)
}
val path = Paths.get(document.storagePath)
try {
Files.deleteIfExists(path)
} catch (_: Exception) {
throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to delete file")
}
guestDocumentRepo.delete(document)
guestDocumentEvents.emit(propertyId, guestId)
}
private fun runExtraction(documentId: UUID, propertyId: UUID, guestId: UUID) {
extractionQueue.enqueue {
val document = guestDocumentRepo.findById(documentId).orElse(null) ?: return@enqueue
try {
val token = tokenService.createToken(document.id.toString())
val imageUrl =
"${aiBaseUrl}/properties/$propertyId/guests/$guestId/documents/${document.id}/file?token=$token"
val publicImageUrl =
"${publicBaseUrl.trimEnd('/')}/properties/$propertyId/guests/$guestId/documents/${document.id}/file?token=$token"
val extraction = extractionService.extractAndApply(imageUrl, publicImageUrl, document, propertyId)
val results = extraction.results
document.extractedData = objectMapper.writeValueAsString(results)
document.extractedAt = OffsetDateTime.now()
guestDocumentRepo.save(document)
guestDocumentEvents.emit(propertyId, guestId)
} catch (ex: Exception) {
logger.warn("Document extraction failed for documentId={}", document.id, ex)
}
}
}
private fun hashFile(storagePath: String): String? {
return try {
val path = Paths.get(storagePath)
if (!Files.exists(path)) return null
val digest = MessageDigest.getInstance("SHA-256")
Files.newInputStream(path).use { input ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var read = input.read(buffer)
while (read >= 0) {
if (read > 0) {
digest.update(buffer, 0, read)
}
read = input.read(buffer)
}
}
digest.digest().joinToString("") { "%02x".format(it) }
} catch (_: Exception) {
null
}
}
}

View File

@@ -1,245 +0,0 @@
package com.android.trisolarisserver.controller.guest
import com.android.trisolarisserver.controller.common.requireMember
import com.android.trisolarisserver.controller.common.requireProperty
import com.android.trisolarisserver.controller.common.requirePropertyGuest
import com.android.trisolarisserver.controller.common.requireRole
import com.android.trisolarisserver.component.auth.PropertyAccess
import com.android.trisolarisserver.component.storage.GuestSignatureStorage
import com.android.trisolarisserver.controller.dto.property.GuestResponse
import com.android.trisolarisserver.controller.dto.property.GuestUpdateRequest
import com.android.trisolarisserver.controller.dto.property.GuestVehicleRequest
import com.android.trisolarisserver.controller.dto.property.GuestVisitCountResponse
import com.android.trisolarisserver.models.booking.Guest
import com.android.trisolarisserver.models.booking.GuestVehicle
import com.android.trisolarisserver.repo.booking.BookingRepo
import com.android.trisolarisserver.repo.guest.GuestRepo
import com.android.trisolarisserver.repo.guest.GuestRatingRepo
import com.android.trisolarisserver.repo.guest.GuestVehicleRepo
import com.android.trisolarisserver.repo.property.PropertyRepo
import com.android.trisolarisserver.security.MyPrincipal
import org.springframework.core.io.FileSystemResource
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.*
import org.springframework.web.multipart.MultipartFile
import org.springframework.web.server.ResponseStatusException
import java.time.OffsetDateTime
import java.util.UUID
@RestController
@RequestMapping("/properties/{propertyId}/guests")
class Guests(
private val propertyAccess: PropertyAccess,
private val propertyRepo: PropertyRepo,
private val guestRepo: GuestRepo,
private val bookingRepo: BookingRepo,
private val guestVehicleRepo: GuestVehicleRepo,
private val guestRatingRepo: GuestRatingRepo,
private val signatureStorage: GuestSignatureStorage
) {
@PutMapping("/{guestId}")
fun updateGuest(
@PathVariable propertyId: UUID,
@PathVariable guestId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: GuestUpdateRequest
): GuestResponse {
requireMember(propertyAccess, propertyId, principal)
val (_, guest) = requirePropertyGuest(propertyRepo, guestRepo, propertyId, guestId)
val phone = request.phoneE164?.trim()?.takeIf { it.isNotBlank() }
if (phone != null) {
val existing = guestRepo.findByPropertyIdAndPhoneE164(propertyId, phone)
if (existing != null && existing.id != guest.id) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Phone number already exists")
}
guest.phoneE164 = phone
}
val name = request.name?.trim()?.ifBlank { null }
val nationality = request.nationality?.trim()?.ifBlank { null }
val address = request.addressText?.trim()?.ifBlank { null }
if (name != null) guest.name = name
if (nationality != null) guest.nationality = nationality
if (address != null) guest.addressText = address
guest.updatedAt = OffsetDateTime.now()
val saved = guestRepo.save(guest)
return setOf(saved).toResponse(propertyId, guestVehicleRepo, guestRatingRepo).first()
}
@GetMapping("/search")
fun search(
@PathVariable propertyId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestParam(required = false) phone: String?,
@RequestParam(required = false) vehicleNumber: String?
): List<GuestResponse> {
requireMember(propertyAccess, propertyId, principal)
if (phone.isNullOrBlank() && vehicleNumber.isNullOrBlank()) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "phone or vehicleNumber required")
}
requireProperty(propertyRepo, propertyId)
val guests = mutableSetOf<Guest>()
if (!phone.isNullOrBlank()) {
val guest = guestRepo.findByPropertyIdAndPhoneE164(propertyId, phone)
if (guest != null) guests.add(guest)
}
if (!vehicleNumber.isNullOrBlank()) {
val vehicle = guestVehicleRepo.findByPropertyIdAndVehicleNumberIgnoreCase(propertyId, vehicleNumber)
if (vehicle != null) guests.add(vehicle.guest)
}
return guests.toResponse(propertyId, guestVehicleRepo, guestRatingRepo)
}
@GetMapping("/{guestId}")
fun getGuest(
@PathVariable propertyId: UUID,
@PathVariable guestId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
): GuestResponse {
requireMember(propertyAccess, propertyId, principal)
val (_, guest) = requirePropertyGuest(propertyRepo, guestRepo, propertyId, guestId)
return setOf(guest).toResponse(propertyId, guestVehicleRepo, guestRatingRepo).first()
}
@GetMapping("/visit-count")
fun getVisitCount(
@PathVariable propertyId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestParam phone: String
): GuestVisitCountResponse {
requireMember(propertyAccess, propertyId, principal)
val phoneValue = phone.trim().ifBlank {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "phone required")
}
requireProperty(propertyRepo, propertyId)
val guest = guestRepo.findByPropertyIdAndPhoneE164(propertyId, phoneValue)
val count = guest?.id?.let { bookingRepo.countByPrimaryGuestId(it) } ?: 0L
return GuestVisitCountResponse(guestId = guest?.id, bookingCount = count)
}
@PostMapping("/{guestId}/vehicles")
@ResponseStatus(HttpStatus.CREATED)
fun addVehicle(
@PathVariable propertyId: UUID,
@PathVariable guestId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: GuestVehicleRequest
): GuestResponse {
requireMember(propertyAccess, propertyId, principal)
val (property, guest) = requirePropertyGuest(propertyRepo, guestRepo, propertyId, guestId)
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 != null && booking.primaryGuest?.id != guest.id) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Booking linked to different guest")
}
val existing = guestVehicleRepo.findByPropertyIdAndVehicleNumberIgnoreCase(property.id!!, request.vehicleNumber)
if (existing != null) {
if (existing.guest.id != guest.id) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Vehicle number already exists")
}
existing.booking = booking
guestVehicleRepo.save(existing)
} else {
val vehicle = GuestVehicle(
property = property,
guest = guest,
booking = booking,
vehicleNumber = request.vehicleNumber.trim()
)
guestVehicleRepo.save(vehicle)
}
return setOf(guest).toResponse(propertyId, guestVehicleRepo, guestRatingRepo).first()
}
@PostMapping("/{guestId}/signature")
@ResponseStatus(HttpStatus.CREATED)
fun uploadSignature(
@PathVariable propertyId: UUID,
@PathVariable guestId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestPart("file") file: MultipartFile
): GuestResponse {
requireRole(propertyAccess, propertyId, principal, com.android.trisolarisserver.models.property.Role.ADMIN, com.android.trisolarisserver.models.property.Role.MANAGER)
if (file.isEmpty) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "File is empty")
}
val contentType = file.contentType ?: ""
if (!contentType.lowercase().startsWith("image/svg+xml")) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Only SVG allowed")
}
val (property, guest) = requirePropertyGuest(propertyRepo, guestRepo, propertyId, guestId)
val stored = signatureStorage.store(propertyId, guestId, file)
guest.signaturePath = stored.storagePath
guest.signatureUpdatedAt = OffsetDateTime.now()
guestRepo.save(guest)
return setOf(guest).toResponse(propertyId, guestVehicleRepo, guestRatingRepo).first()
}
@GetMapping("/{guestId}/signature/file")
fun downloadSignature(
@PathVariable propertyId: UUID,
@PathVariable guestId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
): ResponseEntity<FileSystemResource> {
requireMember(propertyAccess, propertyId, principal)
val (_, guest) = requirePropertyGuest(propertyRepo, guestRepo, propertyId, guestId)
val path = guest.signaturePath ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Signature not found")
val resource = FileSystemResource(signatureStorage.resolvePath(path))
if (!resource.exists()) {
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Signature not found")
}
return ResponseEntity.ok()
.contentType(MediaType.valueOf("image/svg+xml"))
.body(resource)
}
}
private fun Set<Guest>.toResponse(
propertyId: UUID,
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!!,
name = guest.name,
phoneE164 = guest.phoneE164,
dob = guest.age?.trim()?.ifBlank { null },
nationality = guest.nationality,
addressText = guest.addressText,
signatureUrl = guest.signaturePath?.let {
"/properties/$propertyId/guests/${guest.id}/signature/file"
},
vehicleNumbers = vehiclesByGuest[guest.id]?.map { it.vehicleNumber }?.toSet() ?: emptySet(),
averageScore = averages[guest.id]
)
}
}

View File

@@ -1,115 +0,0 @@
package com.android.trisolarisserver.controller.payment
import com.android.trisolarisserver.controller.common.nowForProperty
import com.android.trisolarisserver.controller.common.parseOffset
import com.android.trisolarisserver.controller.common.requireMember
import com.android.trisolarisserver.controller.common.requireRole
import com.android.trisolarisserver.component.booking.BookingEvents
import com.android.trisolarisserver.component.auth.PropertyAccess
import com.android.trisolarisserver.controller.dto.payment.ChargeCreateRequest
import com.android.trisolarisserver.controller.dto.payment.ChargeResponse
import com.android.trisolarisserver.repo.booking.BookingRepo
import com.android.trisolarisserver.models.booking.Charge
import com.android.trisolarisserver.models.booking.ChargeType
import com.android.trisolarisserver.models.property.Role
import com.android.trisolarisserver.repo.property.AppUserRepo
import com.android.trisolarisserver.repo.booking.ChargeRepo
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.util.UUID
@RestController
@RequestMapping("/properties/{propertyId}/bookings/{bookingId}/charges")
class Charges(
private val propertyAccess: PropertyAccess,
private val bookingRepo: BookingRepo,
private val chargeRepo: ChargeRepo,
private val appUserRepo: AppUserRepo,
private val bookingEvents: BookingEvents
) {
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
@Transactional
fun create(
@PathVariable propertyId: UUID,
@PathVariable bookingId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: ChargeCreateRequest
): ChargeResponse {
val actor = requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER, Role.FINANCE)
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")
}
if (request.amount <= 0) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "amount must be > 0")
}
val type = parseType(request.type)
val occurredAt = parseOffset(request.occurredAt) ?: nowForProperty(booking.property.timezone)
val createdBy = appUserRepo.findById(actor.userId).orElse(null)
val charge = Charge(
property = booking.property,
booking = booking,
type = type,
amount = request.amount,
currency = request.currency.trim(),
notes = request.notes?.trim()?.ifBlank { null },
occurredAt = occurredAt,
createdBy = createdBy
)
val saved = chargeRepo.save(charge).toResponse()
bookingEvents.emit(propertyId, bookingId)
return saved
}
@GetMapping
fun list(
@PathVariable propertyId: UUID,
@PathVariable bookingId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
): List<ChargeResponse> {
requireMember(propertyAccess, propertyId, principal)
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 chargeRepo.findByBookingIdOrderByOccurredAtDesc(bookingId).map { it.toResponse() }
}
private fun parseType(value: String): ChargeType {
return try {
ChargeType.valueOf(value.trim().uppercase())
} catch (_: Exception) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown charge type")
}
}
}
private fun Charge.toResponse(): ChargeResponse {
val id = id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Charge id missing")
return ChargeResponse(
id = id,
bookingId = booking.id!!,
type = type.name,
amount = amount,
currency = currency,
occurredAt = occurredAt,
notes = notes
)
}

View File

@@ -1,166 +0,0 @@
package com.android.trisolarisserver.controller.payment
import com.android.trisolarisserver.controller.common.parseOffset
import com.android.trisolarisserver.controller.common.requireMember
import com.android.trisolarisserver.controller.common.requireRole
import com.android.trisolarisserver.component.booking.BookingEvents
import com.android.trisolarisserver.component.auth.PropertyAccess
import com.android.trisolarisserver.controller.dto.payment.PaymentCreateRequest
import com.android.trisolarisserver.controller.dto.payment.PaymentResponse
import com.android.trisolarisserver.repo.booking.BookingRepo
import com.android.trisolarisserver.models.booking.BookingStatus
import com.android.trisolarisserver.models.booking.Payment
import com.android.trisolarisserver.models.booking.PaymentMethod
import com.android.trisolarisserver.models.property.Role
import com.android.trisolarisserver.repo.property.AppUserRepo
import com.android.trisolarisserver.repo.booking.PaymentRepo
import com.android.trisolarisserver.repo.property.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.GetMapping
import org.springframework.web.bind.annotation.DeleteMapping
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.LocalDate
import java.time.OffsetDateTime
import java.util.UUID
@RestController
@RequestMapping("/properties/{propertyId}/bookings/{bookingId}/payments")
class Payments(
private val propertyAccess: PropertyAccess,
private val propertyRepo: PropertyRepo,
private val bookingRepo: BookingRepo,
private val paymentRepo: PaymentRepo,
private val appUserRepo: AppUserRepo,
private val bookingEvents: BookingEvents
) {
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
fun create(
@PathVariable propertyId: UUID,
@PathVariable bookingId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: PaymentCreateRequest
): PaymentResponse {
val actor = requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER, Role.STAFF)
val property = propertyRepo.findById(propertyId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
}
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")
}
if (request.amount <= 0) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "amount must be > 0")
}
val method = request.method?.let { parseMethod(it) } ?: PaymentMethod.CASH
val receivedAt = parseOffset(request.receivedAt) ?: OffsetDateTime.now()
val payment = Payment(
property = property,
booking = booking,
amount = request.amount,
currency = request.currency ?: property.currency,
method = method,
reference = request.reference?.trim()?.ifBlank { null },
notes = request.notes?.trim()?.ifBlank { null },
receivedAt = receivedAt,
receivedBy = appUserRepo.findById(actor.userId).orElse(null)
)
val saved = paymentRepo.save(payment).toResponse()
bookingEvents.emit(propertyId, bookingId)
return saved
}
@GetMapping
fun list(
@PathVariable propertyId: UUID,
@PathVariable bookingId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
): List<PaymentResponse> {
requireMember(propertyAccess, propertyId, principal)
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 paymentRepo.findByBookingIdOrderByReceivedAtDesc(bookingId).map { it.toResponse() }
}
@DeleteMapping("/{paymentId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun delete(
@PathVariable propertyId: UUID,
@PathVariable bookingId: UUID,
@PathVariable paymentId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
) {
requireRole(propertyAccess, propertyId, principal, Role.ADMIN)
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")
}
if (booking.status != BookingStatus.OPEN && booking.status != BookingStatus.CHECKED_IN) {
throw ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Cash payments can only be deleted for OPEN or CHECKED_IN bookings"
)
}
val payment = paymentRepo.findById(paymentId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Payment not found")
}
if (payment.booking.id != bookingId || payment.property.id != propertyId) {
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Payment not found for booking")
}
if (payment.method != PaymentMethod.CASH) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Only CASH payments can be deleted")
}
paymentRepo.delete(payment)
bookingEvents.emit(propertyId, bookingId)
}
private fun parseMethod(value: String): PaymentMethod {
return try {
PaymentMethod.valueOf(value.trim())
} catch (_: IllegalArgumentException) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown payment method")
}
}
}
private fun Payment.toResponse(): PaymentResponse {
val id = id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Payment id missing")
val bookingId = booking.id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Booking id missing")
return PaymentResponse(
id = id,
bookingId = bookingId,
amount = amount,
currency = currency,
method = method.name,
gatewayPaymentId = gatewayPaymentId,
gatewayTxnId = gatewayTxnId,
bankRefNum = bankRefNum,
mode = mode,
pgType = pgType,
payerVpa = payerVpa,
payerName = payerName,
paymentSource = paymentSource,
reference = reference,
notes = notes,
receivedAt = receivedAt.toString(),
receivedByUserId = receivedBy?.id
)
}

View File

@@ -1,90 +0,0 @@
package com.android.trisolarisserver.controller.property
import com.android.trisolarisserver.component.auth.PropertyAccess
import com.android.trisolarisserver.controller.common.requireRole
import com.android.trisolarisserver.controller.dto.property.CancellationPolicyResponse
import com.android.trisolarisserver.controller.dto.property.CancellationPolicyUpsertRequest
import com.android.trisolarisserver.models.property.CancellationPenaltyMode
import com.android.trisolarisserver.models.property.PropertyCancellationPolicy
import com.android.trisolarisserver.models.property.Role
import com.android.trisolarisserver.repo.property.PropertyCancellationPolicyRepo
import com.android.trisolarisserver.repo.property.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.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
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}/cancellation-policy")
class CancellationPolicies(
private val propertyAccess: PropertyAccess,
private val propertyRepo: PropertyRepo,
private val policyRepo: PropertyCancellationPolicyRepo
) {
@GetMapping
fun get(
@PathVariable propertyId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
): CancellationPolicyResponse {
val policy = policyRepo.findByPropertyId(propertyId)
return if (policy != null) {
CancellationPolicyResponse(
freeDaysBeforeCheckin = policy.freeDaysBeforeCheckin,
penaltyMode = policy.penaltyMode.name
)
} else {
CancellationPolicyResponse(
freeDaysBeforeCheckin = 0,
penaltyMode = CancellationPenaltyMode.FULL_STAY.name
)
}
}
@PutMapping
fun upsert(
@PathVariable propertyId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: CancellationPolicyUpsertRequest
): CancellationPolicyResponse {
requireRole(propertyAccess, propertyId, principal, Role.ADMIN)
if (request.freeDaysBeforeCheckin < 0) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "freeDaysBeforeCheckin must be >= 0")
}
val mode = try {
CancellationPenaltyMode.valueOf(request.penaltyMode.trim().uppercase())
} catch (_: Exception) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown penaltyMode")
}
val property = propertyRepo.findById(propertyId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
}
val existing = policyRepo.findByPropertyId(propertyId)
val saved = if (existing != null) {
existing.freeDaysBeforeCheckin = request.freeDaysBeforeCheckin
existing.penaltyMode = mode
existing.updatedAt = OffsetDateTime.now()
policyRepo.save(existing)
} else {
policyRepo.save(
PropertyCancellationPolicy(
property = property,
freeDaysBeforeCheckin = request.freeDaysBeforeCheckin,
penaltyMode = mode
)
)
}
return CancellationPolicyResponse(
freeDaysBeforeCheckin = saved.freeDaysBeforeCheckin,
penaltyMode = saved.penaltyMode.name
)
}
}

View File

@@ -1,417 +0,0 @@
package com.android.trisolarisserver.controller.property
import com.android.trisolarisserver.controller.common.requireMember
import com.android.trisolarisserver.controller.common.requirePrincipal
import com.android.trisolarisserver.controller.common.requireUser
import com.android.trisolarisserver.component.auth.PropertyAccess
import com.android.trisolarisserver.controller.dto.property.PropertyCodeResponse
import com.android.trisolarisserver.controller.dto.property.PropertyBillingPolicyRequest
import com.android.trisolarisserver.controller.dto.property.PropertyBillingPolicyResponse
import com.android.trisolarisserver.controller.dto.property.PropertyCreateRequest
import com.android.trisolarisserver.controller.dto.property.PropertyResponse
import com.android.trisolarisserver.controller.dto.property.PropertyUserDisableRequest
import com.android.trisolarisserver.controller.dto.property.PropertyUpdateRequest
import com.android.trisolarisserver.controller.dto.property.PropertyUserResponse
import com.android.trisolarisserver.controller.dto.property.PropertyUserRoleRequest
import com.android.trisolarisserver.repo.property.AppUserRepo
import com.android.trisolarisserver.repo.property.PropertyRepo
import com.android.trisolarisserver.repo.property.PropertyUserRepo
import com.android.trisolarisserver.models.property.Property
import com.android.trisolarisserver.models.property.PropertyUser
import com.android.trisolarisserver.models.property.PropertyUserId
import com.android.trisolarisserver.models.property.Role
import com.android.trisolarisserver.security.MyPrincipal
import com.android.trisolarisserver.models.booking.TransportMode
import org.springframework.http.HttpStatus
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.DeleteMapping
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.PutMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.server.ResponseStatusException
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException
import java.util.UUID
@RestController
class Properties(
private val propertyAccess: PropertyAccess,
private val propertyRepo: PropertyRepo,
private val propertyUserRepo: PropertyUserRepo,
private val appUserRepo: AppUserRepo
) {
private val codeRandom = java.security.SecureRandom()
private val codeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
@PostMapping("/properties")
@ResponseStatus(HttpStatus.CREATED)
fun createProperty(
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: PropertyCreateRequest
): PropertyResponse {
val user = requireUser(appUserRepo, principal)
val code = generatePropertyCode()
val property = Property(
code = code,
name = request.name,
addressText = request.addressText,
timezone = request.timezone ?: "Asia/Kolkata",
currency = request.currency ?: "INR",
billingCheckinTime = validateBillingTime(request.billingCheckinTime, "billingCheckinTime", "12:00"),
billingCheckoutTime = validateBillingTime(request.billingCheckoutTime, "billingCheckoutTime", "11:00"),
active = request.active ?: true,
otaAliases = request.otaAliases?.toMutableSet() ?: mutableSetOf(),
emailAddresses = request.emailAddresses?.toMutableSet() ?: mutableSetOf(),
allowedTransportModes = request.allowedTransportModes?.let { parseTransportModes(it) } ?: mutableSetOf()
)
val saved = propertyRepo.save(property)
val creatorId = user.id ?: throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "User id missing")
val propertyUserId = PropertyUserId(propertyId = saved.id!!, userId = creatorId)
if (!propertyUserRepo.existsById(propertyUserId)) {
propertyUserRepo.save(
PropertyUser(
id = propertyUserId,
property = saved,
user = user,
roles = mutableSetOf(Role.ADMIN)
)
)
}
return saved.toResponse()
}
@GetMapping("/properties")
fun listProperties(
@AuthenticationPrincipal principal: MyPrincipal?
): List<PropertyResponse> {
val user = requireUser(appUserRepo, principal)
return if (user.superAdmin) {
propertyRepo.findAll().map { it.toResponse() }
} else {
val propertyIds = propertyUserRepo.findByIdUserId(user.id!!).map { it.id.propertyId!! }
propertyRepo.findAllById(propertyIds).map { it.toResponse() }
}
}
@GetMapping("/properties/{propertyId}/code")
fun getPropertyCode(
@PathVariable propertyId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
): PropertyCodeResponse {
requirePrincipal(principal)
propertyAccess.requireMember(propertyId, principal!!.userId)
val property = propertyRepo.findById(propertyId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
}
return PropertyCodeResponse(code = property.code)
}
@GetMapping("/properties/{propertyId}/billing-policy")
fun getBillingPolicy(
@PathVariable propertyId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
): PropertyBillingPolicyResponse {
requirePrincipal(principal)
propertyAccess.requireMember(propertyId, principal!!.userId)
val property = propertyRepo.findById(propertyId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
}
return PropertyBillingPolicyResponse(
propertyId = property.id!!,
billingCheckinTime = property.billingCheckinTime,
billingCheckoutTime = property.billingCheckoutTime
)
}
@PutMapping("/properties/{propertyId}/billing-policy")
fun updateBillingPolicy(
@PathVariable propertyId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: PropertyBillingPolicyRequest
): PropertyBillingPolicyResponse {
requirePrincipal(principal)
propertyAccess.requireMember(propertyId, principal!!.userId)
propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN)
val property = propertyRepo.findById(propertyId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
}
property.billingCheckinTime = validateBillingTime(
request.billingCheckinTime,
"billingCheckinTime",
property.billingCheckinTime
)
property.billingCheckoutTime = validateBillingTime(
request.billingCheckoutTime,
"billingCheckoutTime",
property.billingCheckoutTime
)
val saved = propertyRepo.save(property)
return PropertyBillingPolicyResponse(
propertyId = saved.id!!,
billingCheckinTime = saved.billingCheckinTime,
billingCheckoutTime = saved.billingCheckoutTime
)
}
@GetMapping("/properties/{propertyId}/users")
fun listPropertyUsers(
@PathVariable propertyId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
): List<PropertyUserResponse> {
requirePrincipal(principal)
propertyAccess.requireMember(propertyId, principal!!.userId)
propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN, Role.MANAGER)
val actorUser = appUserRepo.findById(principal.userId).orElse(null)
val actorRoles = propertyUserRepo.findRolesByPropertyAndUser(propertyId, principal.userId)
val actorRank = rankForUser(actorUser?.superAdmin == true, actorRoles)
val users = propertyUserRepo.findByPropertyIdWithUser(propertyId)
return users
.filter { it.id.userId != principal.userId }
.filter { actorRank >= rankForUser(it.user.superAdmin, it.roles) }
.map {
PropertyUserResponse(
userId = it.id.userId!!,
propertyId = it.id.propertyId!!,
roles = it.roles.map { role -> role.name }.toSet()
)
}
}
@PutMapping("/properties/{propertyId}/users/{userId}/roles")
fun upsertPropertyUserRoles(
@PathVariable propertyId: UUID,
@PathVariable userId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: PropertyUserRoleRequest
): PropertyUserResponse {
requirePrincipal(principal)
propertyAccess.requireMember(propertyId, principal!!.userId)
val actorUser = appUserRepo.findById(principal.userId).orElse(null)
val actorRoles = propertyUserRepo.findRolesByPropertyAndUser(propertyId, principal.userId)
val allowedRoles = when {
actorUser?.superAdmin == true -> setOf(Role.ADMIN, Role.MANAGER, Role.STAFF, Role.AGENT)
actorRoles.contains(Role.ADMIN) -> setOf(Role.ADMIN, Role.MANAGER, Role.STAFF, Role.AGENT)
actorRoles.contains(Role.MANAGER) -> setOf(Role.STAFF, Role.AGENT)
else -> emptySet()
}
if (allowedRoles.isEmpty()) {
throw ResponseStatusException(HttpStatus.FORBIDDEN, "Missing role")
}
val requestedRoles = try {
request.roles.map { Role.valueOf(it) }.toSet()
} catch (ex: IllegalArgumentException) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown role")
}
if (!allowedRoles.containsAll(requestedRoles)) {
throw ResponseStatusException(HttpStatus.FORBIDDEN, "Role not allowed")
}
val property = propertyRepo.findById(propertyId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
}
val targetUser = appUserRepo.findById(userId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "User not found")
}
val propertyUser = PropertyUser(
id = PropertyUserId(propertyId = propertyId, userId = userId),
property = property,
user = targetUser,
roles = requestedRoles.toMutableSet()
)
val saved = propertyUserRepo.save(propertyUser)
return PropertyUserResponse(
userId = saved.id.userId!!,
propertyId = saved.id.propertyId!!,
roles = saved.roles.map { it.name }.toSet()
)
}
@PutMapping("/properties/{propertyId}/users/{userId}/disabled")
fun updatePropertyUserDisabled(
@PathVariable propertyId: UUID,
@PathVariable userId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: PropertyUserDisableRequest
): PropertyUserResponse {
requirePrincipal(principal)
propertyAccess.requireMember(propertyId, principal!!.userId)
val actorUser = appUserRepo.findById(principal.userId).orElse(null)
val actorRoles = propertyUserRepo.findRolesByPropertyAndUser(propertyId, principal.userId)
val targetId = PropertyUserId(propertyId = propertyId, userId = userId)
val target = propertyUserRepo.findById(targetId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "User not found in property")
}
val targetRoles = target.roles
if (actorUser?.superAdmin != true) {
val canAdminManage = actorRoles.contains(Role.ADMIN)
val canManagerManage = actorRoles.contains(Role.MANAGER)
val allowedForManager = setOf(
Role.STAFF,
Role.AGENT,
Role.HOUSEKEEPING,
Role.FINANCE,
Role.GUIDE,
Role.SUPERVISOR
)
val allowed = when {
canAdminManage -> !targetRoles.contains(Role.ADMIN)
canManagerManage -> targetRoles.all { allowedForManager.contains(it) }
else -> false
}
if (!allowed) {
throw ResponseStatusException(HttpStatus.FORBIDDEN, "Role not allowed")
}
}
target.disabled = request.disabled
val saved = propertyUserRepo.save(target)
return PropertyUserResponse(
userId = saved.id.userId!!,
propertyId = saved.id.propertyId!!,
roles = saved.roles.map { it.name }.toSet()
)
}
@DeleteMapping("/properties/{propertyId}/users/{userId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun deletePropertyUser(
@PathVariable propertyId: UUID,
@PathVariable userId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
) {
requirePrincipal(principal)
propertyAccess.requireMember(propertyId, principal!!.userId)
propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN)
val id = PropertyUserId(propertyId = propertyId, userId = userId)
if (propertyUserRepo.existsById(id)) {
propertyUserRepo.deleteById(id)
}
}
@PutMapping("/properties/{propertyId}")
fun updateProperty(
@PathVariable propertyId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: PropertyUpdateRequest
): PropertyResponse {
requirePrincipal(principal)
propertyAccess.requireMember(propertyId, principal!!.userId)
propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN)
val property = propertyRepo.findById(propertyId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
}
if (propertyRepo.existsByCodeAndIdNot(request.code, propertyId)) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Property code already exists")
}
property.code = request.code
property.name = request.name
property.addressText = request.addressText ?: property.addressText
property.timezone = request.timezone ?: property.timezone
property.currency = request.currency ?: property.currency
property.billingCheckinTime = validateBillingTime(
request.billingCheckinTime,
"billingCheckinTime",
property.billingCheckinTime
)
property.billingCheckoutTime = validateBillingTime(
request.billingCheckoutTime,
"billingCheckoutTime",
property.billingCheckoutTime
)
property.active = request.active ?: property.active
if (request.otaAliases != null) {
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()
}
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 validateBillingTime(value: String?, fieldName: String, fallback: String): String {
val candidate = value?.trim()?.takeIf { it.isNotEmpty() } ?: fallback
return try {
LocalTime.parse(candidate, BILLING_TIME_FORMATTER).format(BILLING_TIME_FORMATTER)
} catch (_: DateTimeParseException) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "$fieldName must be HH:mm")
}
}
private fun generatePropertyCode(): String {
repeat(10) {
val code = buildString(7) {
repeat(7) {
append(codeAlphabet[codeRandom.nextInt(codeAlphabet.length)])
}
}
if (!propertyRepo.existsByCode(code)) {
return code
}
}
throw ResponseStatusException(HttpStatus.CONFLICT, "Unable to generate property code")
}
private fun rankForUser(isSuperAdmin: Boolean, roles: Set<Role>): Int {
if (isSuperAdmin) return 500
return roles.maxOfOrNull { roleRank(it) } ?: 0
}
private fun roleRank(role: Role): Int {
return when (role) {
Role.ADMIN -> 400
Role.MANAGER -> 300
Role.STAFF, Role.HOUSEKEEPING, Role.FINANCE, Role.SUPERVISOR, Role.GUIDE -> 200
Role.AGENT -> 100
}
}
companion object {
private val BILLING_TIME_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm")
}
}
private fun Property.toResponse(): PropertyResponse {
val id = id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Property id missing")
return PropertyResponse(
id = id,
code = code,
name = name,
addressText = addressText,
timezone = timezone,
currency = currency,
billingCheckinTime = billingCheckinTime,
billingCheckoutTime = billingCheckoutTime,
active = active,
otaAliases = otaAliases.toSet(),
emailAddresses = emailAddresses.toSet(),
allowedTransportModes = allowedTransportModes.map { it.name }.toSet()
)
}

View File

@@ -1,172 +0,0 @@
package com.android.trisolarisserver.controller.property
import com.android.trisolarisserver.component.auth.PropertyAccess
import com.android.trisolarisserver.controller.common.requirePrincipal
import com.android.trisolarisserver.controller.dto.property.PropertyAccessCodeCreateRequest
import com.android.trisolarisserver.controller.dto.property.PropertyAccessCodeJoinRequest
import com.android.trisolarisserver.controller.dto.property.PropertyAccessCodeResponse
import com.android.trisolarisserver.controller.dto.property.PropertyUserResponse
import com.android.trisolarisserver.models.property.PropertyAccessCode
import com.android.trisolarisserver.models.property.Property
import com.android.trisolarisserver.models.property.PropertyUser
import com.android.trisolarisserver.models.property.PropertyUserId
import com.android.trisolarisserver.models.property.Role
import com.android.trisolarisserver.repo.property.AppUserRepo
import com.android.trisolarisserver.repo.property.PropertyAccessCodeRepo
import com.android.trisolarisserver.repo.property.PropertyRepo
import com.android.trisolarisserver.repo.property.PropertyUserRepo
import com.android.trisolarisserver.security.MyPrincipal
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.RequestBody
import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.server.ResponseStatusException
import org.springframework.transaction.annotation.Transactional
import java.security.SecureRandom
import java.time.OffsetDateTime
import java.util.UUID
@RestController
class PropertyAccessCodes(
private val propertyAccess: PropertyAccess,
private val propertyRepo: PropertyRepo,
private val propertyUserRepo: PropertyUserRepo,
private val accessCodeRepo: PropertyAccessCodeRepo,
private val appUserRepo: AppUserRepo
) {
private val secureRandom = SecureRandom()
@PostMapping("/properties/{propertyId}/access-codes")
@ResponseStatus(HttpStatus.CREATED)
fun createAccessCode(
@PathVariable propertyId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: PropertyAccessCodeCreateRequest
): PropertyAccessCodeResponse {
val resolved = requirePrincipal(principal)
propertyAccess.requireMember(propertyId, resolved.userId)
propertyAccess.requireAnyRole(propertyId, resolved.userId, Role.ADMIN)
val roles = parseRoles(request.roles)
if (roles.contains(Role.ADMIN)) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "ADMIN cannot be invited by code")
}
if (roles.isEmpty()) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "At least one role is required")
}
val property = propertyRepo.findById(propertyId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
}
val actor = appUserRepo.findById(resolved.userId).orElseThrow {
ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found")
}
val now = OffsetDateTime.now()
val expiresAt = now.plusMinutes(1)
val code = generateCode(propertyId, now)
val accessCode = PropertyAccessCode(
property = property,
createdBy = actor,
code = code,
expiresAt = expiresAt,
roles = roles.toMutableSet()
)
accessCodeRepo.save(accessCode)
return PropertyAccessCodeResponse(
propertyId = propertyId,
code = code,
expiresAt = expiresAt,
roles = roles.map { it.name }.toSet()
)
}
@PostMapping("/properties/access-codes/join")
@Transactional
fun joinWithAccessCode(
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: PropertyAccessCodeJoinRequest
): PropertyUserResponse {
val resolved = requirePrincipal(principal)
val code = request.code.trim()
if (code.length != 6 || !code.all { it.isDigit() }) {
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Invalid code")
}
val now = OffsetDateTime.now()
val property = resolveProperty(request)
val accessCode = accessCodeRepo.findActiveByPropertyAndCode(property.id!!, code, now)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Invalid code")
val membershipId = PropertyUserId(propertyId = property.id, userId = resolved.userId)
if (propertyUserRepo.existsById(membershipId)) {
throw ResponseStatusException(HttpStatus.CONFLICT, "User already a member")
}
val user = appUserRepo.findById(resolved.userId).orElseThrow {
ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found")
}
val propertyUser = PropertyUser(
id = membershipId,
property = property,
user = user,
roles = accessCode.roles.toMutableSet()
)
propertyUserRepo.save(propertyUser)
accessCode.usedAt = now
accessCode.usedBy = user
accessCodeRepo.save(accessCode)
return PropertyUserResponse(
userId = membershipId.userId!!,
propertyId = membershipId.propertyId!!,
roles = propertyUser.roles.map { it.name }.toSet()
)
}
private fun resolveProperty(request: PropertyAccessCodeJoinRequest): Property {
val code = request.propertyCode?.trim().orEmpty()
if (code.isNotBlank()) {
return propertyRepo.findByCode(code)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
}
val rawId = request.propertyId?.trim().orEmpty()
if (rawId.isBlank()) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Property code required")
}
val asUuid = runCatching { UUID.fromString(rawId) }.getOrNull()
return if (asUuid != null) {
propertyRepo.findById(asUuid).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
}
} else {
propertyRepo.findByCode(rawId)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
}
}
private fun parseRoles(input: Set<String>): Set<Role> {
return try {
input.map { Role.valueOf(it) }.toSet()
} catch (ex: IllegalArgumentException) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown role")
}
}
private fun generateCode(propertyId: UUID, now: OffsetDateTime): String {
repeat(6) {
val value = secureRandom.nextInt(1_000_000)
val code = String.format("%06d", value)
if (!accessCodeRepo.existsActiveByPropertyAndCode(propertyId, code, now)) {
return code
}
}
throw ResponseStatusException(HttpStatus.CONFLICT, "Unable to generate code, try again")
}
}

View File

@@ -1,99 +0,0 @@
package com.android.trisolarisserver.controller.property
import com.android.trisolarisserver.component.auth.PropertyAccess
import com.android.trisolarisserver.controller.common.requirePrincipal
import com.android.trisolarisserver.controller.common.requireSuperAdmin
import com.android.trisolarisserver.controller.dto.property.AppUserSummaryResponse
import com.android.trisolarisserver.controller.dto.property.PropertyUserDetailsResponse
import com.android.trisolarisserver.models.property.Role
import com.android.trisolarisserver.repo.property.AppUserRepo
import com.android.trisolarisserver.repo.property.PropertyUserRepo
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.RequestParam
import org.springframework.web.bind.annotation.RestController
import java.util.UUID
@RestController
class UserDirectory(
private val appUserRepo: AppUserRepo,
private val propertyUserRepo: PropertyUserRepo,
private val propertyAccess: PropertyAccess
) {
@GetMapping("/users")
fun listAppUsers(
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestParam(required = false) phone: String?
): List<AppUserSummaryResponse> {
val actor = requireSuperAdmin(appUserRepo, principal)
val digits = phone?.filter { it.isDigit() }.orEmpty()
val users = when {
phone == null -> appUserRepo.findAll()
digits.length < 6 -> return emptyList()
else -> appUserRepo.findByPhoneE164Containing(digits)
}
return users.filter { it.id != actor.id }.map {
AppUserSummaryResponse(
id = it.id!!,
phoneE164 = it.phoneE164,
name = it.name,
disabled = it.disabled,
superAdmin = it.superAdmin
)
}
}
@GetMapping("/properties/{propertyId}/users/search")
fun searchPropertyUsers(
@PathVariable propertyId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestParam(required = false) phone: String?
): List<PropertyUserDetailsResponse> {
val resolved = requirePrincipal(principal)
propertyAccess.requireMember(propertyId, resolved.userId)
propertyAccess.requireAnyRole(propertyId, resolved.userId, Role.ADMIN)
val actorUser = appUserRepo.findById(resolved.userId).orElse(null)
val actorRoles = propertyUserRepo.findRolesByPropertyAndUser(propertyId, resolved.userId)
val actorRank = rankForUser(actorUser?.superAdmin == true, actorRoles)
val digits = phone?.filter { it.isDigit() }.orEmpty()
val users = when {
phone == null -> propertyUserRepo.findByPropertyIdWithUser(propertyId)
digits.length < 6 -> return emptyList()
else -> propertyUserRepo.findByPropertyIdAndPhoneLike(propertyId, digits)
}
return users
.filter { it.id.userId != resolved.userId }
.filter { actorRank >= rankForUser(it.user.superAdmin, it.roles) }
.map {
val user = it.user
PropertyUserDetailsResponse(
userId = it.id.userId!!,
propertyId = it.id.propertyId!!,
roles = it.roles.map { role -> role.name }.toSet(),
name = user.name,
phoneE164 = user.phoneE164,
disabled = user.disabled,
superAdmin = user.superAdmin
)
}
}
private fun rankForUser(isSuperAdmin: Boolean, roles: Set<Role>): Int {
if (isSuperAdmin) return 500
return roles.maxOfOrNull { roleRank(it) } ?: 0
}
private fun roleRank(role: Role): Int {
return when (role) {
Role.ADMIN -> 400
Role.MANAGER -> 300
Role.STAFF, Role.HOUSEKEEPING, Role.FINANCE, Role.SUPERVISOR, Role.GUIDE -> 200
Role.AGENT -> 100
}
}
}

View File

@@ -1,254 +0,0 @@
package com.android.trisolarisserver.controller.rate
import com.android.trisolarisserver.controller.common.parseDate
import com.android.trisolarisserver.controller.common.requireMember
import com.android.trisolarisserver.controller.common.requireRole
import com.android.trisolarisserver.component.auth.PropertyAccess
import com.android.trisolarisserver.controller.dto.rate.RateCalendarResponse
import com.android.trisolarisserver.controller.dto.rate.RateCalendarAverageResponse
import com.android.trisolarisserver.controller.dto.rate.RateCalendarRangeUpsertRequest
import com.android.trisolarisserver.controller.dto.rate.RatePlanCreateRequest
import com.android.trisolarisserver.controller.dto.rate.RatePlanResponse
import com.android.trisolarisserver.controller.dto.rate.RatePlanUpdateRequest
import com.android.trisolarisserver.repo.property.PropertyRepo
import com.android.trisolarisserver.repo.rate.RateCalendarRepo
import com.android.trisolarisserver.repo.rate.RatePlanRepo
import com.android.trisolarisserver.repo.room.RoomTypeRepo
import com.android.trisolarisserver.models.property.Role
import com.android.trisolarisserver.models.room.RateCalendar
import com.android.trisolarisserver.models.room.RatePlan
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.DeleteMapping
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.PutMapping
import org.springframework.web.bind.annotation.RequestBody
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.server.ResponseStatusException
import java.time.LocalDate
import java.time.OffsetDateTime
import java.util.UUID
@RestController
@RequestMapping("/properties/{propertyId}/rate-plans")
class RatePlans(
private val propertyAccess: PropertyAccess,
private val propertyRepo: PropertyRepo,
private val roomTypeRepo: RoomTypeRepo,
private val ratePlanRepo: RatePlanRepo,
private val rateCalendarRepo: RateCalendarRepo
) {
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
fun create(
@PathVariable propertyId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: RatePlanCreateRequest
): RatePlanResponse {
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER)
val property = propertyRepo.findById(propertyId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
}
val roomType = roomTypeRepo.findByPropertyIdAndCodeIgnoreCase(propertyId, request.roomTypeCode)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room type not found")
if (ratePlanRepo.existsByPropertyIdAndRoomTypeIdAndCode(propertyId, roomType.id!!, request.code.trim())) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Rate plan code already exists for room type")
}
val plan = RatePlan(
property = property,
roomType = roomType,
code = request.code.trim(),
name = request.name.trim(),
baseRate = request.baseRate,
currency = request.currency ?: property.currency,
updatedAt = OffsetDateTime.now()
)
return ratePlanRepo.save(plan).toResponse()
}
@GetMapping
fun list(
@PathVariable propertyId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestParam(required = false) roomTypeCode: String?
): List<RatePlanResponse> {
requireMember(propertyAccess, propertyId, principal)
val plans = if (roomTypeCode.isNullOrBlank()) {
ratePlanRepo.findByPropertyIdOrderByCode(propertyId)
} else {
ratePlanRepo.findByPropertyIdOrderByCode(propertyId)
.filter { it.roomType.code.equals(roomTypeCode, ignoreCase = true) }
}
return plans.map { it.toResponse() }
}
@PutMapping("/{ratePlanId}")
fun update(
@PathVariable propertyId: UUID,
@PathVariable ratePlanId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: RatePlanUpdateRequest
): RatePlanResponse {
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER)
val plan = ratePlanRepo.findByIdAndPropertyId(ratePlanId, propertyId)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Rate plan not found")
plan.name = request.name.trim()
plan.baseRate = request.baseRate
plan.currency = request.currency ?: plan.currency
plan.updatedAt = OffsetDateTime.now()
return ratePlanRepo.save(plan).toResponse()
}
@DeleteMapping("/{ratePlanId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
@Transactional
fun delete(
@PathVariable propertyId: UUID,
@PathVariable ratePlanId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
) {
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER)
val plan = ratePlanRepo.findByIdAndPropertyId(ratePlanId, propertyId)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Rate plan not found")
rateCalendarRepo.findByRatePlanId(plan.id!!).forEach { rateCalendarRepo.delete(it) }
ratePlanRepo.delete(plan)
}
@PostMapping("/{ratePlanId}/calendar")
@ResponseStatus(HttpStatus.CREATED)
@Transactional
fun upsertCalendar(
@PathVariable propertyId: UUID,
@PathVariable ratePlanId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: RateCalendarRangeUpsertRequest
): List<RateCalendarResponse> {
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER)
val plan = ratePlanRepo.findByIdAndPropertyId(ratePlanId, propertyId)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Rate plan not found")
val fromDate = parseDate(request.from, "Invalid date")
val toDate = parseDate(request.to, "Invalid date")
if (toDate.isBefore(fromDate)) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "to must be on/after from")
}
val existing = rateCalendarRepo.findByRatePlanIdAndRateDateBetweenOrderByRateDateAsc(
plan.id!!,
fromDate,
toDate
).associateBy { it.rateDate }
val updates = mutableListOf<RateCalendar>()
for (date in datesBetween(fromDate, toDate)) {
val row = existing[date]
if (row != null) {
row.rate = request.rate
updates.add(row)
} else {
updates.add(
RateCalendar(
property = plan.property,
ratePlan = plan,
rateDate = date,
rate = request.rate
)
)
}
}
return rateCalendarRepo.saveAll(updates).map { it.toResponse() }
}
@GetMapping("/{ratePlanId}/calendar")
fun listCalendar(
@PathVariable propertyId: UUID,
@PathVariable ratePlanId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestParam from: String,
@RequestParam to: String
): RateCalendarAverageResponse {
requireMember(propertyAccess, propertyId, principal)
val plan = ratePlanRepo.findByIdAndPropertyId(ratePlanId, propertyId)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Rate plan not found")
val fromDate = parseDate(from, "Invalid date")
val toDate = parseDate(to, "Invalid date")
if (toDate.isBefore(fromDate)) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "to must be on/after from")
}
val items = rateCalendarRepo.findByRatePlanIdAndRateDateBetweenOrderByRateDateAsc(plan.id!!, fromDate, toDate)
val overrides = items.associateBy { it.rateDate }
var total = 0L
var days = 0
for (date in datesBetween(fromDate, toDate)) {
days += 1
total += overrides[date]?.rate ?: plan.baseRate
}
val average = if (days == 0) 0.0 else total.toDouble() / days
return RateCalendarAverageResponse(
ratePlanId = plan.id!!,
from = fromDate,
to = toDate,
averageRate = average,
days = days,
currency = plan.currency
)
}
@DeleteMapping("/{ratePlanId}/calendar/{rateDate}")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun deleteCalendar(
@PathVariable propertyId: UUID,
@PathVariable ratePlanId: UUID,
@PathVariable rateDate: String,
@AuthenticationPrincipal principal: MyPrincipal?
) {
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER)
val plan = ratePlanRepo.findByIdAndPropertyId(ratePlanId, propertyId)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Rate plan not found")
val date = parseDate(rateDate, "Invalid date")
val existing = rateCalendarRepo.findByRatePlanIdAndRateDate(plan.id!!, date)
?: return
rateCalendarRepo.delete(existing)
}
private fun datesBetween(from: LocalDate, to: LocalDate): Sequence<LocalDate> {
return generateSequence(from) { current ->
val next = current.plusDays(1)
if (next.isAfter(to)) null else next
}
}
}
private fun RatePlan.toResponse(): RatePlanResponse {
val id = id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Rate plan id missing")
val propertyId = property.id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Property id missing")
val roomTypeId = roomType.id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Room type id missing")
return RatePlanResponse(
id = id,
propertyId = propertyId,
roomTypeId = roomTypeId,
roomTypeCode = roomType.code,
code = code,
name = name,
baseRate = baseRate,
currency = currency
)
}
private fun RateCalendar.toResponse(): RateCalendarResponse {
val id = id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Rate calendar id missing")
val planId = ratePlan.id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Rate plan id missing")
return RateCalendarResponse(
id = id,
ratePlanId = planId,
rateDate = rateDate,
rate = rate
)
}

View File

@@ -1,194 +0,0 @@
package com.android.trisolarisserver.controller.razorpay
import com.android.trisolarisserver.controller.common.requireRole
import com.android.trisolarisserver.component.auth.PropertyAccess
import com.android.trisolarisserver.controller.dto.razorpay.RazorpayPaymentLinkCreateRequest
import com.android.trisolarisserver.controller.dto.razorpay.RazorpayPaymentLinkCreateResponse
import com.android.trisolarisserver.models.booking.BookingStatus
import com.android.trisolarisserver.models.payment.RazorpayPaymentLinkRequest
import com.android.trisolarisserver.models.property.Role
import com.android.trisolarisserver.repo.booking.BookingRepo
import com.android.trisolarisserver.repo.razorpay.RazorpayPaymentLinkRequestRepo
import com.android.trisolarisserver.repo.razorpay.RazorpaySettingsRepo
import com.android.trisolarisserver.security.MyPrincipal
import com.fasterxml.jackson.databind.ObjectMapper
import org.springframework.http.HttpEntity
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpMethod
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
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.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.client.RestTemplate
import org.springframework.web.server.ResponseStatusException
import org.springframework.http.HttpStatus
import org.springframework.transaction.annotation.Transactional
import java.time.OffsetDateTime
import java.util.Base64
import java.util.UUID
@RestController
@RequestMapping("/properties/{propertyId}/bookings/{bookingId}/payments/razorpay")
class RazorpayPaymentLinksController(
private val propertyAccess: PropertyAccess,
private val bookingRepo: BookingRepo,
private val settingsRepo: RazorpaySettingsRepo,
private val linkRequestRepo: RazorpayPaymentLinkRequestRepo,
private val restTemplate: RestTemplate,
private val objectMapper: ObjectMapper
) {
@PostMapping("/payment-link")
@Transactional
fun createPaymentLink(
@PathVariable propertyId: UUID,
@PathVariable bookingId: UUID,
@RequestBody request: RazorpayPaymentLinkCreateRequest,
@AuthenticationPrincipal principal: MyPrincipal?
): RazorpayPaymentLinkCreateResponse {
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER, Role.STAFF)
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")
}
if (booking.status == BookingStatus.CANCELLED || booking.status == BookingStatus.NO_SHOW) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Booking is not active")
}
val settings = settingsRepo.findByPropertyId(propertyId)
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Razorpay settings not configured")
val amount = request.amount ?: 0L
if (amount <= 0) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "amount must be > 0")
}
val currency = booking.property.currency
val existing = linkRequestRepo.findTopByBookingIdAndAmountAndCurrencyAndStatusOrderByCreatedAtDesc(
bookingId,
amount,
currency,
"created"
)
if (existing != null && existing.paymentLinkId != null) {
return RazorpayPaymentLinkCreateResponse(
amount = existing.amount,
currency = existing.currency,
paymentLink = existing.shortUrl
)
}
val guest = booking.primaryGuest
val guestName = guest?.name?.trim()?.ifBlank { null }
val guestPhone = guest?.phoneE164?.trim()?.ifBlank { null }
val notes = mapOf(
"bookingId" to bookingId.toString(),
"propertyId" to propertyId.toString()
)
val payload = linkedMapOf<String, Any>(
"amount" to amount * 100,
"currency" to currency,
"description" to (request.description ?: "Booking $bookingId"),
"notes" to notes,
// Razorpay requires reference_id to be unique per active link
"reference_id" to "bk_${bookingId.toString().replace("-", "").take(12)}_${OffsetDateTime.now().toEpochSecond()}"
)
if (guestName != null || guestPhone != null) {
val customer = linkedMapOf<String, Any>()
guestName?.let { customer["name"] = it }
guestPhone?.let { customer["contact"] = it }
payload["customer"] = customer
}
parseExpiryEpoch(request.expiryDate)?.let { payload["expire_by"] = it }
request.isPartialPaymentAllowed?.let { payload["partial_payment"] = it }
request.minAmountForCustomer?.let { payload["first_min_partial_amount"] = it * 100 }
request.successUrl?.let { payload["callback_url"] = it }
if (payload["callback_url"] == null && request.failureUrl != null) {
payload["callback_url"] = request.failureUrl
}
val notify = linkedMapOf<String, Any>()
request.viaEmail?.let { notify["email"] = it }
request.viaSms?.let { notify["sms"] = it }
if (notify.isNotEmpty()) {
payload["notify"] = notify
}
val requestPayload = objectMapper.writeValueAsString(payload)
val response = postJson(resolveBaseUrl(settings.isTest) + "/payment_links", settings, requestPayload)
val body = response.body ?: "{}"
val node = objectMapper.readTree(body)
val linkId = node.path("id").asText(null)
val status = node.path("status").asText("unknown")
val shortUrl = node.path("short_url").asText(null)
val record = linkRequestRepo.save(
RazorpayPaymentLinkRequest(
property = booking.property,
booking = booking,
paymentLinkId = linkId,
amount = amount,
currency = currency,
status = status,
shortUrl = shortUrl,
requestPayload = requestPayload,
responsePayload = body,
createdAt = OffsetDateTime.now()
)
)
if (!response.statusCode.is2xxSuccessful) {
record.status = "failed"
linkRequestRepo.save(record)
throw ResponseStatusException(HttpStatus.BAD_GATEWAY, "Razorpay request failed")
}
return RazorpayPaymentLinkCreateResponse(
amount = amount,
currency = currency,
paymentLink = shortUrl
)
}
private fun postJson(url: String, settings: com.android.trisolarisserver.models.payment.RazorpaySettings, json: String): ResponseEntity<String> {
val (keyId, keySecret) = requireKeys(settings)
val headers = HttpHeaders()
headers.contentType = MediaType.APPLICATION_JSON
headers.set(HttpHeaders.AUTHORIZATION, basicAuth(keyId, keySecret))
return restTemplate.exchange(url, HttpMethod.POST, HttpEntity(json, headers), String::class.java)
}
private fun resolveBaseUrl(isTest: Boolean): String {
return if (isTest) {
"https://api.razorpay.com/v1"
} else {
"https://api.razorpay.com/v1"
}
}
private fun basicAuth(keyId: String, keySecret: String): String {
val raw = "$keyId:$keySecret"
val encoded = Base64.getEncoder().encodeToString(raw.toByteArray())
return "Basic $encoded"
}
private fun requireKeys(settings: com.android.trisolarisserver.models.payment.RazorpaySettings): Pair<String, String> {
val keyId = settings.resolveKeyId()
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Razorpay test keys not configured")
val keySecret = settings.resolveKeySecret()
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Razorpay test keys not configured")
return keyId to keySecret
}
private fun parseExpiryEpoch(value: String?): Long? {
if (value.isNullOrBlank()) return null
return value.trim().toLongOrNull()
}
}

View File

@@ -1,204 +0,0 @@
package com.android.trisolarisserver.controller.razorpay
import com.android.trisolarisserver.controller.common.requireRole
import com.android.trisolarisserver.component.auth.PropertyAccess
import com.android.trisolarisserver.controller.dto.razorpay.RazorpayPaymentRequestCloseRequest
import com.android.trisolarisserver.controller.dto.razorpay.RazorpayPaymentRequestCloseResponse
import com.android.trisolarisserver.controller.dto.razorpay.RazorpayPaymentRequestResponse
import com.android.trisolarisserver.repo.booking.BookingRepo
import com.android.trisolarisserver.models.property.Role
import com.android.trisolarisserver.repo.razorpay.RazorpayPaymentLinkRequestRepo
import com.android.trisolarisserver.repo.razorpay.RazorpayQrRequestRepo
import com.android.trisolarisserver.repo.razorpay.RazorpaySettingsRepo
import com.android.trisolarisserver.security.MyPrincipal
import com.fasterxml.jackson.databind.ObjectMapper
import org.springframework.http.HttpEntity
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpMethod
import org.springframework.http.MediaType
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.RestController
import org.springframework.web.client.RestTemplate
import org.springframework.web.server.ResponseStatusException
import java.util.Base64
import java.util.UUID
@RestController
@RequestMapping("/properties/{propertyId}/bookings/{bookingId}/payments/razorpay")
class RazorpayPaymentRequestsController(
private val propertyAccess: PropertyAccess,
private val bookingRepo: BookingRepo,
private val qrRequestRepo: RazorpayQrRequestRepo,
private val paymentLinkRequestRepo: RazorpayPaymentLinkRequestRepo,
private val settingsRepo: RazorpaySettingsRepo,
private val restTemplate: RestTemplate,
private val objectMapper: ObjectMapper
) {
@GetMapping("/requests")
fun listRequests(
@PathVariable propertyId: UUID,
@PathVariable bookingId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
): List<RazorpayPaymentRequestResponse> {
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER, Role.STAFF)
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")
}
val qrItems = qrRequestRepo.findByBookingIdOrderByCreatedAtDesc(bookingId)
.filter { !isQrClosed(it.status) }
.map { qr ->
RazorpayPaymentRequestResponse(
type = "QR",
requestId = qr.id!!,
amount = qr.amount,
currency = qr.currency,
status = qr.status,
createdAt = qr.createdAt.toString(),
qrId = qr.qrId,
imageUrl = qr.imageUrl,
expiryAt = qr.expiryAt?.toString()
)
}
val linkItems = paymentLinkRequestRepo.findByBookingIdOrderByCreatedAtDesc(bookingId)
.filter { !isLinkClosed(it.status) }
.map { link ->
RazorpayPaymentRequestResponse(
type = "PAYMENT_LINK",
requestId = link.id!!,
amount = link.amount,
currency = link.currency,
status = link.status,
createdAt = link.createdAt.toString(),
paymentLinkId = link.paymentLinkId,
paymentLink = link.shortUrl
)
}
return (qrItems + linkItems).sortedByDescending { it.createdAt }
}
@PostMapping("/close")
fun closeRequest(
@PathVariable propertyId: UUID,
@PathVariable bookingId: UUID,
@RequestBody request: RazorpayPaymentRequestCloseRequest,
@AuthenticationPrincipal principal: MyPrincipal?
): RazorpayPaymentRequestCloseResponse {
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER, Role.STAFF)
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")
}
val qrId = request.qrId?.trim()?.ifBlank { null }
val linkId = request.paymentLinkId?.trim()?.ifBlank { null }
if ((qrId == null && linkId == null) || (qrId != null && linkId != null)) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Provide exactly one of qrId or paymentLinkId")
}
val settings = settingsRepo.findByPropertyId(propertyId)
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Razorpay settings not configured")
if (qrId != null) {
val record = qrRequestRepo.findTopByQrIdOrderByCreatedAtDesc(qrId)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "QR not found")
if (record.booking.id != bookingId) {
throw ResponseStatusException(HttpStatus.NOT_FOUND, "QR not found for booking")
}
val response = postJson(resolveBaseUrl(settings.isTest) + "/payments/qr_codes/$qrId/close", settings, "{}")
if (!response.statusCode.is2xxSuccessful) {
throw ResponseStatusException(HttpStatus.BAD_GATEWAY, "Razorpay close request failed")
}
record.status = "closed"
qrRequestRepo.save(record)
return RazorpayPaymentRequestCloseResponse(
type = "QR",
qrId = qrId,
status = "closed"
)
}
val paymentLinkId = linkId!!
val record = paymentLinkRequestRepo.findTopByPaymentLinkIdOrderByCreatedAtDesc(paymentLinkId)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Payment link not found")
if (record.booking.id != bookingId) {
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Payment link not found for booking")
}
val response = postJson(resolveBaseUrl(settings.isTest) + "/payment_links/$paymentLinkId/cancel", settings, "{}")
if (!response.statusCode.is2xxSuccessful) {
throw ResponseStatusException(HttpStatus.BAD_GATEWAY, "Razorpay cancel request failed")
}
val body = response.body ?: "{}"
val status = runCatching { objectMapper.readTree(body).path("status").asText(null) }.getOrNull()
?: "cancelled"
record.status = status
paymentLinkRequestRepo.save(record)
return RazorpayPaymentRequestCloseResponse(
type = "PAYMENT_LINK",
paymentLinkId = paymentLinkId,
status = status
)
}
private fun postJson(
url: String,
settings: com.android.trisolarisserver.models.payment.RazorpaySettings,
json: String
): org.springframework.http.ResponseEntity<String> {
val (keyId, keySecret) = requireKeys(settings)
val headers = HttpHeaders()
headers.contentType = MediaType.APPLICATION_JSON
headers.set(HttpHeaders.AUTHORIZATION, basicAuth(keyId, keySecret))
return restTemplate.exchange(url, HttpMethod.POST, HttpEntity(json, headers), String::class.java)
}
private fun resolveBaseUrl(isTest: Boolean): String {
return if (isTest) {
"https://api.razorpay.com/v1"
} else {
"https://api.razorpay.com/v1"
}
}
private fun basicAuth(keyId: String, keySecret: String): String {
val raw = "$keyId:$keySecret"
val encoded = Base64.getEncoder().encodeToString(raw.toByteArray())
return "Basic $encoded"
}
private fun requireKeys(settings: com.android.trisolarisserver.models.payment.RazorpaySettings): Pair<String, String> {
val keyId = settings.resolveKeyId()
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Razorpay test keys not configured")
val keySecret = settings.resolveKeySecret()
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Razorpay test keys not configured")
return keyId to keySecret
}
private fun isQrClosed(status: String?): Boolean {
return when (status?.lowercase()) {
"closed", "expired", "credited" -> true
else -> false
}
}
private fun isLinkClosed(status: String?): Boolean {
return when (status?.lowercase()) {
"cancelled", "canceled", "paid", "expired" -> true
else -> false
}
}
}

View File

@@ -1,347 +0,0 @@
package com.android.trisolarisserver.controller.razorpay
import com.android.trisolarisserver.controller.common.requireRole
import com.android.trisolarisserver.component.auth.PropertyAccess
import com.android.trisolarisserver.component.razorpay.RazorpayQrEvents
import com.android.trisolarisserver.controller.dto.razorpay.RazorpayQrGenerateRequest
import com.android.trisolarisserver.controller.dto.razorpay.RazorpayQrEventResponse
import com.android.trisolarisserver.controller.dto.razorpay.RazorpayQrGenerateResponse
import com.android.trisolarisserver.controller.dto.razorpay.RazorpayQrRecordResponse
import com.android.trisolarisserver.models.booking.BookingStatus
import com.android.trisolarisserver.models.payment.RazorpayQrRequest
import com.android.trisolarisserver.models.property.Role
import com.android.trisolarisserver.repo.booking.BookingRepo
import com.android.trisolarisserver.repo.razorpay.RazorpayQrRequestRepo
import com.android.trisolarisserver.repo.razorpay.RazorpaySettingsRepo
import com.android.trisolarisserver.repo.razorpay.RazorpayWebhookLogRepo
import com.android.trisolarisserver.security.MyPrincipal
import com.fasterxml.jackson.databind.ObjectMapper
import jakarta.servlet.http.HttpServletResponse
import org.springframework.http.HttpEntity
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpMethod
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.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.client.RestTemplate
import org.springframework.web.server.ResponseStatusException
import org.springframework.http.HttpStatus
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter
import java.time.OffsetDateTime
import java.util.Base64
import java.util.UUID
@RestController
@RequestMapping("/properties/{propertyId}/bookings/{bookingId}/payments/razorpay")
class RazorpayQrPayments(
private val propertyAccess: PropertyAccess,
private val bookingRepo: BookingRepo,
private val settingsRepo: RazorpaySettingsRepo,
private val qrRequestRepo: RazorpayQrRequestRepo,
private val webhookLogRepo: RazorpayWebhookLogRepo,
private val qrEvents: RazorpayQrEvents,
private val restTemplate: RestTemplate,
private val objectMapper: ObjectMapper
) {
@PostMapping("/qr")
@Transactional
fun createQr(
@PathVariable propertyId: UUID,
@PathVariable bookingId: UUID,
@RequestBody request: RazorpayQrGenerateRequest,
@AuthenticationPrincipal principal: MyPrincipal?
): RazorpayQrGenerateResponse {
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER, Role.STAFF)
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")
}
if (booking.status == BookingStatus.CANCELLED || booking.status == BookingStatus.NO_SHOW) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Booking is not active")
}
val settings = settingsRepo.findByPropertyId(propertyId)
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Razorpay settings not configured")
val amount = request.amount ?: 0L
if (amount <= 0) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "amount must be > 0")
}
val currency = booking.property.currency
val existing = qrRequestRepo.findTopByBookingIdAndAmountAndCurrencyAndStatusOrderByCreatedAtDesc(
bookingId,
amount,
currency,
"active"
)
if (existing != null && existing.qrId != null) {
return RazorpayQrGenerateResponse(
qrId = existing.qrId,
amount = existing.amount,
currency = existing.currency,
imageUrl = existing.imageUrl
)
}
val expirySeconds = request.expirySeconds ?: request.expiryMinutes?.let { it * 60 } ?: 600
val expiresAt = expirySeconds?.let { OffsetDateTime.now().plusSeconds(it.toLong()) }
val guest = booking.primaryGuest
val guestName = guest?.name?.trim()?.ifBlank { null }
val guestPhone = guest?.phoneE164?.trim()?.ifBlank { null }
val notes = linkedMapOf(
"bookingId" to bookingId.toString(),
"propertyId" to propertyId.toString()
)
guestName?.let { notes["guestName"] = it }
guestPhone?.let { notes["guestPhone"] = it }
val payload = linkedMapOf<String, Any>(
"type" to "upi_qr",
"name" to "Booking $bookingId",
"usage" to "single_use",
"fixed_amount" to true,
"payment_amount" to amount * 100,
"notes" to notes
)
payload["close_by"] = OffsetDateTime.now().plusSeconds(expirySeconds.toLong()).toEpochSecond()
val requestPayload = objectMapper.writeValueAsString(payload)
val response = postJson(resolveBaseUrl(settings.isTest) + "/payments/qr_codes", settings, requestPayload)
val body = response.body ?: "{}"
val node = objectMapper.readTree(body)
val qrId = node.path("id").asText(null)
val status = node.path("status").asText("unknown")
val imageUrl = node.path("image_url").asText(null)
val record = qrRequestRepo.save(
RazorpayQrRequest(
property = booking.property,
booking = booking,
qrId = qrId,
amount = amount,
currency = currency,
status = status,
imageUrl = imageUrl,
requestPayload = requestPayload,
responsePayload = body,
expiryAt = expiresAt
)
)
if (!response.statusCode.is2xxSuccessful) {
record.status = "failed"
qrRequestRepo.save(record)
throw ResponseStatusException(HttpStatus.BAD_GATEWAY, "Razorpay request failed")
}
return RazorpayQrGenerateResponse(
qrId = qrId,
amount = amount,
currency = currency,
imageUrl = imageUrl
)
}
@GetMapping("/qr/active")
fun getActiveQr(
@PathVariable propertyId: UUID,
@PathVariable bookingId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
): RazorpayQrGenerateResponse? {
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER, Role.STAFF)
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")
}
val active = qrRequestRepo.findTopByBookingIdAndStatusOrderByCreatedAtDesc(bookingId, "active") ?: return null
return RazorpayQrGenerateResponse(
qrId = active.qrId,
amount = active.amount,
currency = active.currency,
imageUrl = active.imageUrl
)
}
@PostMapping("/qr/close")
@Transactional
fun closeActiveQr(
@PathVariable propertyId: UUID,
@PathVariable bookingId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
): RazorpayQrGenerateResponse? {
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER, Role.STAFF)
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")
}
val active = qrRequestRepo.findTopByBookingIdAndStatusOrderByCreatedAtDesc(bookingId, "active") ?: return null
val settings = settingsRepo.findByPropertyId(propertyId)
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Razorpay settings not configured")
val qrId = active.qrId ?: return null
val response = postJson(resolveBaseUrl(settings.isTest) + "/payments/qr_codes/$qrId/close", settings, "{}")
if (!response.statusCode.is2xxSuccessful) {
throw ResponseStatusException(HttpStatus.BAD_GATEWAY, "Razorpay close request failed")
}
active.status = "closed"
qrRequestRepo.save(active)
return RazorpayQrGenerateResponse(
qrId = active.qrId,
amount = active.amount,
currency = active.currency,
imageUrl = active.imageUrl
)
}
@PostMapping("/qr/{qrId}/close")
@Transactional
fun closeQrById(
@PathVariable propertyId: UUID,
@PathVariable bookingId: UUID,
@PathVariable qrId: String,
@AuthenticationPrincipal principal: MyPrincipal?
): RazorpayQrGenerateResponse? {
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER, Role.STAFF)
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")
}
val record = qrRequestRepo.findTopByQrIdOrderByCreatedAtDesc(qrId)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "QR not found")
val settings = settingsRepo.findByPropertyId(propertyId)
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Razorpay settings not configured")
val response = postJson(resolveBaseUrl(settings.isTest) + "/payments/qr_codes/$qrId/close", settings, "{}")
if (!response.statusCode.is2xxSuccessful) {
throw ResponseStatusException(HttpStatus.BAD_GATEWAY, "Razorpay close request failed")
}
record.status = "closed"
qrRequestRepo.save(record)
return RazorpayQrGenerateResponse(
qrId = record.qrId,
amount = record.amount,
currency = record.currency,
imageUrl = record.imageUrl
)
}
@GetMapping("/qr/{qrId}/events")
fun qrEvents(
@PathVariable propertyId: UUID,
@PathVariable qrId: String,
@AuthenticationPrincipal principal: MyPrincipal?
): List<RazorpayQrEventResponse> {
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER, Role.STAFF)
val logs = webhookLogRepo.findByPropertyIdOrderByReceivedAtDesc(propertyId)
val out = mutableListOf<RazorpayQrEventResponse>()
for (log in logs) {
val payload = log.payload ?: continue
val node = runCatching { objectMapper.readTree(payload) }.getOrNull() ?: continue
val event = node.path("event").asText(null)
val qrEntity = node.path("payload").path("qr_code").path("entity")
val eventQrId = qrEntity.path("id").asText(null)
if (eventQrId != qrId) continue
val status = qrEntity.path("status").asText(null)
out.add(
RazorpayQrEventResponse(
event = event,
qrId = eventQrId,
status = status,
receivedAt = log.receivedAt.toString()
)
)
}
return out
}
@GetMapping("/qr/{qrId}/events/stream", produces = [MediaType.TEXT_EVENT_STREAM_VALUE])
fun streamQrEvents(
@PathVariable propertyId: UUID,
@PathVariable bookingId: UUID,
@PathVariable qrId: String,
@AuthenticationPrincipal principal: MyPrincipal?,
response: HttpServletResponse
): SseEmitter {
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER, Role.STAFF)
prepareSse(response)
return qrEvents.subscribe(propertyId, qrId)
}
private fun prepareSse(response: HttpServletResponse) {
response.setHeader("Cache-Control", "no-cache")
response.setHeader("Connection", "keep-alive")
response.setHeader("X-Accel-Buffering", "no")
}
@GetMapping("/qr")
fun listQr(
@PathVariable propertyId: UUID,
@PathVariable bookingId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
): List<RazorpayQrRecordResponse> {
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER, Role.STAFF)
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 qrRequestRepo.findByBookingIdOrderByCreatedAtDesc(bookingId).map { qr ->
RazorpayQrRecordResponse(
qrId = qr.qrId,
amount = qr.amount,
currency = qr.currency,
status = qr.status,
imageUrl = qr.imageUrl,
expiryAt = qr.expiryAt?.toString(),
createdAt = qr.createdAt.toString(),
responsePayload = qr.responsePayload
)
}
}
private fun postJson(url: String, settings: com.android.trisolarisserver.models.payment.RazorpaySettings, json: String): ResponseEntity<String> {
val (keyId, keySecret) = requireKeys(settings)
val headers = HttpHeaders()
headers.contentType = MediaType.APPLICATION_JSON
headers.set(HttpHeaders.AUTHORIZATION, basicAuth(keyId, keySecret))
return restTemplate.exchange(url, HttpMethod.POST, HttpEntity(json, headers), String::class.java)
}
private fun resolveBaseUrl(isTest: Boolean): String {
return if (isTest) {
"https://api.razorpay.com/v1"
} else {
"https://api.razorpay.com/v1"
}
}
private fun basicAuth(keyId: String, keySecret: String): String {
val raw = "$keyId:$keySecret"
val encoded = Base64.getEncoder().encodeToString(raw.toByteArray())
return "Basic $encoded"
}
private fun requireKeys(settings: com.android.trisolarisserver.models.payment.RazorpaySettings): Pair<String, String> {
val keyId = settings.resolveKeyId()
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Razorpay test keys not configured")
val keySecret = settings.resolveKeySecret()
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Razorpay test keys not configured")
return keyId to keySecret
}
}

View File

@@ -1,137 +0,0 @@
package com.android.trisolarisserver.controller.razorpay
import com.android.trisolarisserver.controller.common.requireRole
import com.android.trisolarisserver.component.auth.PropertyAccess
import com.android.trisolarisserver.controller.dto.razorpay.RazorpayRefundRequest
import com.android.trisolarisserver.controller.dto.razorpay.RazorpayRefundResponse
import com.android.trisolarisserver.repo.booking.BookingRepo
import com.android.trisolarisserver.models.property.Role
import com.android.trisolarisserver.repo.booking.PaymentRepo
import com.android.trisolarisserver.repo.razorpay.RazorpaySettingsRepo
import com.android.trisolarisserver.security.MyPrincipal
import com.fasterxml.jackson.databind.ObjectMapper
import org.springframework.http.HttpEntity
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpMethod
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
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.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.client.RestTemplate
import org.springframework.web.server.ResponseStatusException
import java.util.Base64
import java.util.UUID
@RestController
@RequestMapping("/properties/{propertyId}/bookings/{bookingId}/payments/razorpay")
class RazorpayRefundsController(
private val propertyAccess: PropertyAccess,
private val bookingRepo: BookingRepo,
private val paymentRepo: PaymentRepo,
private val settingsRepo: RazorpaySettingsRepo,
private val restTemplate: RestTemplate,
private val objectMapper: ObjectMapper
) {
@PostMapping("/refund")
fun refund(
@PathVariable propertyId: UUID,
@PathVariable bookingId: UUID,
@RequestBody request: RazorpayRefundRequest,
@AuthenticationPrincipal principal: MyPrincipal?
): RazorpayRefundResponse {
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER, Role.FINANCE)
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")
}
val paymentId = request.paymentId
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "paymentId is required")
val payment = paymentRepo.findById(paymentId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Payment not found")
}
if (payment.booking.id != bookingId || payment.property.id != propertyId) {
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Payment not found for booking")
}
request.amount?.let {
if (it > payment.amount) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "amount must be <= payment amount")
}
}
val gatewayPaymentId = payment.gatewayPaymentId
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Payment is missing gateway id")
if (!gatewayPaymentId.startsWith("pay_")) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Payment is not a Razorpay payment")
}
val settings = settingsRepo.findByPropertyId(propertyId)
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Razorpay settings not configured")
val payload = linkedMapOf<String, Any>()
request.amount?.let {
if (it <= 0) throw ResponseStatusException(HttpStatus.BAD_REQUEST, "amount must be > 0")
payload["amount"] = it * 100
}
request.notes?.trim()?.takeIf { it.isNotBlank() }?.let { payload["notes"] = mapOf("note" to it) }
val response = postJson(resolveBaseUrl(settings.isTest) + "/payments/$gatewayPaymentId/refund", settings, objectMapper.writeValueAsString(payload))
if (!response.statusCode.is2xxSuccessful) {
throw ResponseStatusException(HttpStatus.BAD_GATEWAY, "Razorpay refund request failed")
}
val body = response.body ?: "{}"
val node = objectMapper.readTree(body)
val refundId = node.path("id").asText(null)
val status = node.path("status").asText(null)
val amount = node.path("amount").asLong(0).let { if (it == 0L) null else it / 100 }
val currency = node.path("currency").asText(null)
return RazorpayRefundResponse(
refundId = refundId,
status = status,
amount = amount,
currency = currency
)
}
private fun postJson(
url: String,
settings: com.android.trisolarisserver.models.payment.RazorpaySettings,
json: String
): ResponseEntity<String> {
val (keyId, keySecret) = requireKeys(settings)
val headers = HttpHeaders()
headers.contentType = MediaType.APPLICATION_JSON
headers.set(HttpHeaders.AUTHORIZATION, basicAuth(keyId, keySecret))
return restTemplate.exchange(url, HttpMethod.POST, HttpEntity(json, headers), String::class.java)
}
private fun resolveBaseUrl(isTest: Boolean): String {
return if (isTest) {
"https://api.razorpay.com/v1"
} else {
"https://api.razorpay.com/v1"
}
}
private fun basicAuth(keyId: String, keySecret: String): String {
val raw = "$keyId:$keySecret"
val encoded = Base64.getEncoder().encodeToString(raw.toByteArray())
return "Basic $encoded"
}
private fun requireKeys(settings: com.android.trisolarisserver.models.payment.RazorpaySettings): Pair<String, String> {
val keyId = settings.resolveKeyId()
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Razorpay test keys not configured")
val keySecret = settings.resolveKeySecret()
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Razorpay test keys not configured")
return keyId to keySecret
}
}

View File

@@ -1,25 +0,0 @@
package com.android.trisolarisserver.controller.razorpay
import org.springframework.http.HttpStatus
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.ResponseStatus
import org.springframework.web.bind.annotation.RestController
import java.util.UUID
@RestController
@RequestMapping("/properties/{propertyId}/razorpay/return")
class RazorpayReturnController {
@PostMapping("/success")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun success(@PathVariable propertyId: UUID) {
// Razorpay redirect target; no-op.
}
@PostMapping("/failure")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun failure(@PathVariable propertyId: UUID) {
// Razorpay redirect target; no-op.
}
}

View File

@@ -1,138 +0,0 @@
package com.android.trisolarisserver.controller.razorpay
import com.android.trisolarisserver.controller.common.requireRole
import com.android.trisolarisserver.component.auth.PropertyAccess
import com.android.trisolarisserver.controller.dto.razorpay.RazorpaySettingsResponse
import com.android.trisolarisserver.controller.dto.razorpay.RazorpaySettingsUpsertRequest
import com.android.trisolarisserver.models.payment.RazorpaySettings
import com.android.trisolarisserver.models.property.Role
import com.android.trisolarisserver.repo.property.PropertyRepo
import com.android.trisolarisserver.repo.razorpay.RazorpaySettingsRepo
import com.android.trisolarisserver.security.MyPrincipal
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.server.ResponseStatusException
import org.springframework.http.HttpStatus
import org.springframework.security.core.annotation.AuthenticationPrincipal
import java.time.OffsetDateTime
import java.util.UUID
@RestController
@RequestMapping("/properties/{propertyId}/razorpay-settings")
class RazorpaySettingsController(
private val propertyAccess: PropertyAccess,
private val propertyRepo: PropertyRepo,
private val settingsRepo: RazorpaySettingsRepo
) {
@GetMapping
fun getSettings(
@PathVariable propertyId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
): RazorpaySettingsResponse {
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER)
val settings = settingsRepo.findByPropertyId(propertyId)
return if (settings == null) {
RazorpaySettingsResponse(
propertyId = propertyId,
configured = false,
isTest = false,
hasKeyId = false,
hasKeySecret = false,
hasWebhookSecret = false,
hasKeyIdTest = false,
hasKeySecretTest = false,
hasWebhookSecretTest = false
)
} else {
settings.toResponse()
}
}
@PutMapping
fun upsertSettings(
@PathVariable propertyId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: RazorpaySettingsUpsertRequest
): RazorpaySettingsResponse {
requireRole(propertyAccess, propertyId, principal, Role.ADMIN)
val property = propertyRepo.findById(propertyId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
}
val existing = settingsRepo.findByPropertyId(propertyId)
val keyId = request.keyId?.trim()?.ifBlank { null } ?: request.merchantKey?.trim()?.ifBlank { null }
val keySecret = request.keySecret?.trim()?.ifBlank { null }
?: run {
val prefer256 = request.useSalt256 == true
val candidate = if (prefer256) request.salt256 else request.salt32
candidate?.trim()?.ifBlank { null } ?: request.salt256?.trim()?.ifBlank { null } ?: request.salt32?.trim()?.ifBlank { null }
}
val webhookSecret = request.webhookSecret?.trim()?.ifBlank { null }
val keyIdTest = request.keyIdTest?.trim()?.ifBlank { null }
val keySecretTest = request.keySecretTest?.trim()?.ifBlank { null }
val webhookSecretTest = request.webhookSecretTest?.trim()?.ifBlank { null }
val isTest = request.isTest
val hasKeyId = keyId != null
val hasKeySecret = keySecret != null
if (hasKeyId != hasKeySecret) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "keyId and keySecret must be provided together")
}
val hasKeyIdTest = keyIdTest != null
val hasKeySecretTest = keySecretTest != null
if (hasKeyIdTest != hasKeySecretTest) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "keyIdTest and keySecretTest must be provided together")
}
if (existing == null && (keyId == null || keySecret == null)) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "keyId/keySecret required")
}
if (isTest == true || existing?.isTest == true) {
val effectiveKeyIdTest = keyIdTest ?: existing?.keyIdTest
val effectiveKeySecretTest = keySecretTest ?: existing?.keySecretTest
if (effectiveKeyIdTest.isNullOrBlank() || effectiveKeySecretTest.isNullOrBlank()) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "keyIdTest/keySecretTest required when isTest=true")
}
}
val updated = if (existing == null) {
RazorpaySettings(
property = property,
keyId = keyId!!,
keySecret = keySecret!!,
webhookSecret = webhookSecret,
keyIdTest = keyIdTest,
keySecretTest = keySecretTest,
webhookSecretTest = webhookSecretTest,
isTest = isTest ?: false,
updatedAt = OffsetDateTime.now()
)
} else {
if (keyId != null) existing.keyId = keyId
if (keySecret != null) existing.keySecret = keySecret
if (webhookSecret != null) existing.webhookSecret = webhookSecret
if (keyIdTest != null) existing.keyIdTest = keyIdTest
if (keySecretTest != null) existing.keySecretTest = keySecretTest
if (webhookSecretTest != null) existing.webhookSecretTest = webhookSecretTest
isTest?.let { existing.isTest = it }
existing.updatedAt = OffsetDateTime.now()
existing
}
return settingsRepo.save(updated).toResponse()
}
}
private fun RazorpaySettings.toResponse(): RazorpaySettingsResponse {
return RazorpaySettingsResponse(
propertyId = property.id!!,
configured = true,
isTest = isTest,
hasKeyId = keyId.isNotBlank(),
hasKeySecret = keySecret.isNotBlank(),
hasWebhookSecret = !webhookSecret.isNullOrBlank(),
hasKeyIdTest = !keyIdTest.isNullOrBlank(),
hasKeySecretTest = !keySecretTest.isNullOrBlank(),
hasWebhookSecretTest = !webhookSecretTest.isNullOrBlank()
)
}

View File

@@ -1,213 +0,0 @@
package com.android.trisolarisserver.controller.razorpay
import com.android.trisolarisserver.repo.booking.BookingRepo
import com.android.trisolarisserver.models.booking.Payment
import com.android.trisolarisserver.models.booking.PaymentMethod
import com.android.trisolarisserver.models.payment.RazorpayPaymentAttempt
import com.android.trisolarisserver.models.payment.RazorpayWebhookLog
import com.android.trisolarisserver.component.razorpay.RazorpayQrEvents
import com.android.trisolarisserver.component.booking.BookingEvents
import com.android.trisolarisserver.controller.dto.razorpay.RazorpayQrEventResponse
import com.android.trisolarisserver.repo.booking.PaymentRepo
import com.android.trisolarisserver.repo.property.PropertyRepo
import com.android.trisolarisserver.repo.razorpay.RazorpayPaymentAttemptRepo
import com.android.trisolarisserver.repo.razorpay.RazorpayPaymentLinkRequestRepo
import com.android.trisolarisserver.repo.razorpay.RazorpayQrRequestRepo
import com.android.trisolarisserver.repo.razorpay.RazorpaySettingsRepo
import com.android.trisolarisserver.repo.razorpay.RazorpayWebhookLogRepo
import com.fasterxml.jackson.databind.ObjectMapper
import jakarta.servlet.http.HttpServletRequest
import org.springframework.http.HttpStatus
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
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
@RestController
@RequestMapping("/properties/{propertyId}/razorpay/webhook")
class RazorpayWebhookCapture(
private val propertyRepo: PropertyRepo,
private val bookingRepo: BookingRepo,
private val paymentRepo: PaymentRepo,
private val settingsRepo: RazorpaySettingsRepo,
private val razorpayPaymentAttemptRepo: RazorpayPaymentAttemptRepo,
private val razorpayPaymentLinkRequestRepo: RazorpayPaymentLinkRequestRepo,
private val razorpayQrRequestRepo: RazorpayQrRequestRepo,
private val razorpayWebhookLogRepo: RazorpayWebhookLogRepo,
private val razorpayQrEvents: RazorpayQrEvents,
private val bookingEvents: BookingEvents,
private val objectMapper: ObjectMapper
) {
@PostMapping
@ResponseStatus(HttpStatus.NO_CONTENT)
@Transactional
fun capture(
@PathVariable propertyId: UUID,
@RequestBody(required = false) body: String?,
request: HttpServletRequest
) {
val property = propertyRepo.findById(propertyId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
}
val settings = settingsRepo.findByPropertyId(propertyId)
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Razorpay settings not configured")
val headers = request.headerNames.toList().associateWith { request.getHeader(it) }
val headersText = headers.entries.joinToString("\n") { (k, v) -> "$k: $v" }
razorpayWebhookLogRepo.save(
RazorpayWebhookLog(
property = property,
headers = headersText,
payload = body,
contentType = request.contentType,
receivedAt = OffsetDateTime.now()
)
)
if (body.isNullOrBlank()) return
val signature = request.getHeader("X-Razorpay-Signature")
?: throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing signature")
val secret = settings.resolveWebhookSecret()
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Webhook secret not configured")
if (!verifySignature(body, secret, signature)) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid signature")
}
val root = objectMapper.readTree(body)
val event = root.path("event").asText(null)
val paymentEntity = root.path("payload").path("payment").path("entity")
val orderEntity = root.path("payload").path("order").path("entity")
val qrEntity = root.path("payload").path("qr_code").path("entity")
val paymentLinkEntity = root.path("payload").path("payment_link").path("entity")
val refundEntity = root.path("payload").path("refund").path("entity")
val paymentId = paymentEntity.path("id").asText(null)
val orderId = paymentEntity.path("order_id").asText(null)?.takeIf { it.isNotBlank() }
?: orderEntity.path("id").asText(null)?.takeIf { it.isNotBlank() }
val status = paymentEntity.path("status").asText(null)
val amountPaise = paymentEntity.path("amount").asLong(0)
val currency = paymentEntity.path("currency").asText(property.currency)
val notes = paymentEntity.path("notes")
val bookingId = notes.path("bookingId").asText(null)?.let { runCatching { UUID.fromString(it) }.getOrNull() }
?: orderEntity.path("receipt").asText(null)?.let { runCatching { UUID.fromString(it) }.getOrNull() }
val booking = bookingId?.let { bookingRepo.findById(it).orElse(null) }
if (booking != null && booking.property.id != propertyId) return
val qrId = qrEntity.path("id").asText(null)
val qrStatus = qrEntity.path("status").asText(null)
if (event != null && event.startsWith("qr_code.") && qrId != null) {
val existingQr = razorpayQrRequestRepo.findTopByQrIdOrderByCreatedAtDesc(qrId)
if (existingQr != null) {
if (!qrStatus.isNullOrBlank()) {
existingQr.status = qrStatus
razorpayQrRequestRepo.save(existingQr)
}
}
razorpayQrEvents.emit(
propertyId,
qrId,
RazorpayQrEventResponse(
event = event,
qrId = qrId,
status = qrStatus,
receivedAt = OffsetDateTime.now().toString()
)
)
}
val paymentLinkId = paymentLinkEntity.path("id").asText(null)
val paymentLinkStatus = paymentLinkEntity.path("status").asText(null)
if (event != null && event.startsWith("payment_link.") && paymentLinkId != null) {
val existingLink = razorpayPaymentLinkRequestRepo.findTopByPaymentLinkIdOrderByCreatedAtDesc(paymentLinkId)
if (existingLink != null) {
if (!paymentLinkStatus.isNullOrBlank()) {
existingLink.status = paymentLinkStatus
razorpayPaymentLinkRequestRepo.save(existingLink)
}
}
}
razorpayPaymentAttemptRepo.save(
RazorpayPaymentAttempt(
property = property,
booking = booking,
event = event,
status = status,
amount = paiseToAmount(amountPaise),
currency = currency,
paymentId = paymentId,
orderId = orderId,
payload = body,
receivedAt = OffsetDateTime.now()
)
)
if (event == null || paymentId == null || booking == null) return
if (event != "payment.captured" && event != "refund.processed") return
val refundId = refundEntity.path("id").asText(null)
if (event == "refund.processed") {
refundId?.let {
val existingRefund = paymentRepo.findByReference("razorpay_refund:$it")
if (existingRefund != null) return
}
} else {
if (paymentRepo.findByGatewayPaymentId(paymentId) != null) return
}
val refundAmountPaise = refundEntity.path("amount").asLong(0)
val resolvedAmountPaise = if (event == "refund.processed" && refundAmountPaise > 0) refundAmountPaise else amountPaise
val signedAmount = if (event == "refund.processed") -paiseToAmount(resolvedAmountPaise) else paiseToAmount(resolvedAmountPaise)
val notesText = "razorpay event=$event status=$status order_id=$orderId refund_id=${refundId ?: "-"}"
paymentRepo.save(
Payment(
property = booking.property,
booking = booking,
amount = signedAmount,
currency = booking.property.currency,
method = PaymentMethod.ONLINE,
gatewayPaymentId = paymentId,
gatewayTxnId = orderId,
reference = if (event == "refund.processed" && refundId != null) "razorpay_refund:$refundId" else "razorpay:$paymentId",
notes = notesText,
receivedAt = OffsetDateTime.now()
)
)
bookingEvents.emit(propertyId, booking.id!!)
if (qrId != null) {
razorpayQrEvents.emit(
propertyId,
qrId,
RazorpayQrEventResponse(
event = event,
qrId = qrId,
status = qrStatus,
receivedAt = OffsetDateTime.now().toString()
)
)
}
}
private fun verifySignature(payload: String, secret: String, signature: String): Boolean {
return try {
val mac = Mac.getInstance("HmacSHA256")
mac.init(SecretKeySpec(secret.toByteArray(), "HmacSHA256"))
val hash = mac.doFinal(payload.toByteArray()).joinToString("") { "%02x".format(it) }
hash.equals(signature, ignoreCase = true)
} catch (_: Exception) {
false
}
}
private fun paiseToAmount(paise: Long): Long {
return paise / 100
}
}

View File

@@ -1,128 +0,0 @@
package com.android.trisolarisserver.controller.room
import com.android.trisolarisserver.controller.common.requirePrincipal
import com.android.trisolarisserver.controller.common.requireSuperAdmin
import com.android.trisolarisserver.controller.dto.room.AmenityResponse
import com.android.trisolarisserver.controller.dto.room.AmenityUpsertRequest
import com.android.trisolarisserver.models.room.RoomAmenity
import com.android.trisolarisserver.repo.property.AppUserRepo
import com.android.trisolarisserver.repo.room.RoomAmenityRepo
import com.android.trisolarisserver.repo.room.RoomTypeRepo
import com.android.trisolarisserver.security.MyPrincipal
import org.springframework.http.HttpStatus
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.beans.factory.annotation.Value
import org.springframework.web.bind.annotation.DeleteMapping
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.PutMapping
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.nio.file.Files
import java.nio.file.Paths
import java.util.UUID
@RestController
@RequestMapping("/amenities")
class RoomAmenities(
private val roomAmenityRepo: RoomAmenityRepo,
private val roomTypeRepo: RoomTypeRepo,
private val appUserRepo: AppUserRepo
) {
@Value("\${storage.icons.png.root:/home/androidlover5842/docs/icons/png}")
private lateinit var pngRoot: String
@GetMapping
fun listAmenities(
@AuthenticationPrincipal principal: MyPrincipal?
): List<AmenityResponse> {
requirePrincipal(principal)
return roomAmenityRepo.findAllByOrderByName().map { it.toResponse() }
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
fun createAmenity(
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: AmenityUpsertRequest
): AmenityResponse {
requireSuperAdmin(appUserRepo, principal)
if (roomAmenityRepo.existsByName(request.name)) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Amenity already exists")
}
validateIconKey(request.iconKey)
val amenity = RoomAmenity(
name = request.name,
category = request.category,
iconKey = request.iconKey
)
return roomAmenityRepo.save(amenity).toResponse()
}
@PutMapping("/{amenityId}")
fun updateAmenity(
@PathVariable amenityId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: AmenityUpsertRequest
): AmenityResponse {
requireSuperAdmin(appUserRepo, principal)
val amenity = roomAmenityRepo.findById(amenityId).orElse(null)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Amenity not found")
if (roomAmenityRepo.existsByNameAndIdNot(request.name, amenityId)) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Amenity already exists")
}
validateIconKey(request.iconKey)
amenity.name = request.name
amenity.category = request.category ?: amenity.category
amenity.iconKey = request.iconKey ?: amenity.iconKey
return roomAmenityRepo.save(amenity).toResponse()
}
@DeleteMapping("/{amenityId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
@org.springframework.transaction.annotation.Transactional
fun deleteAmenity(
@PathVariable amenityId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
) {
requireSuperAdmin(appUserRepo, principal)
val amenity = roomAmenityRepo.findById(amenityId).orElse(null)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Amenity not found")
val roomTypes = roomTypeRepo.findAllByAmenitiesId(amenityId)
if (roomTypes.isNotEmpty()) {
for (roomType in roomTypes) {
roomType.amenities.removeIf { it.id == amenityId }
}
roomTypeRepo.saveAll(roomTypes)
}
roomAmenityRepo.delete(amenity)
}
private fun validateIconKey(iconKey: String?) {
if (iconKey.isNullOrBlank()) return
val file = Paths.get(pngRoot, "${iconKey}.png")
if (!Files.exists(file)) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Icon key not found")
}
}
}
private fun RoomAmenity.toResponse(): AmenityResponse {
val id = id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Amenity id missing")
return AmenityResponse(
id = id,
name = name,
category = category,
iconKey = iconKey
)
}

View File

@@ -1,93 +0,0 @@
package com.android.trisolarisserver.controller.room
import com.android.trisolarisserver.controller.common.requireSuperAdmin
import com.android.trisolarisserver.controller.dto.room.RoomImageTagResponse
import com.android.trisolarisserver.controller.dto.room.RoomImageTagUpsertRequest
import com.android.trisolarisserver.models.room.RoomImageTag
import com.android.trisolarisserver.repo.property.AppUserRepo
import com.android.trisolarisserver.repo.room.RoomImageRepo
import com.android.trisolarisserver.repo.room.RoomImageTagRepo
import com.android.trisolarisserver.security.MyPrincipal
import org.springframework.http.HttpStatus
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.DeleteMapping
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.PutMapping
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 org.springframework.transaction.annotation.Transactional
import java.util.UUID
@RestController
@RequestMapping("/image-tags")
class RoomImageTags(
private val roomImageTagRepo: RoomImageTagRepo,
private val roomImageRepo: RoomImageRepo,
private val appUserRepo: AppUserRepo
) {
@GetMapping
fun listTags(
@AuthenticationPrincipal principal: MyPrincipal?
): List<RoomImageTagResponse> {
return roomImageTagRepo.findAllByOrderByName().map { it.toResponse() }
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
fun createTag(
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: RoomImageTagUpsertRequest
): RoomImageTagResponse {
requireSuperAdmin(appUserRepo, principal)
if (roomImageTagRepo.existsByName(request.name)) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Tag already exists")
}
val tag = RoomImageTag(name = request.name)
return roomImageTagRepo.save(tag).toResponse()
}
@PutMapping("/{tagId}")
fun updateTag(
@PathVariable tagId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: RoomImageTagUpsertRequest
): RoomImageTagResponse {
requireSuperAdmin(appUserRepo, principal)
val tag = roomImageTagRepo.findById(tagId).orElse(null)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Tag not found")
if (roomImageTagRepo.existsByNameAndIdNot(request.name, tagId)) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Tag already exists")
}
tag.name = request.name
return roomImageTagRepo.save(tag).toResponse()
}
@DeleteMapping("/{tagId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
@Transactional
fun deleteTag(
@PathVariable tagId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
) {
requireSuperAdmin(appUserRepo, principal)
val tag = roomImageTagRepo.findById(tagId).orElse(null)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Tag not found")
roomImageRepo.deleteTagLinks(tagId)
roomImageTagRepo.delete(tag)
}
}
private fun RoomImageTag.toResponse(): RoomImageTagResponse {
val id = id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Tag id missing")
return RoomImageTagResponse(
id = id,
name = name
)
}

View File

@@ -1,341 +0,0 @@
package com.android.trisolarisserver.controller.room
import com.android.trisolarisserver.controller.common.requireMember
import com.android.trisolarisserver.controller.common.requireRole
import com.android.trisolarisserver.controller.dto.room.RoomImageTagResponse
import com.android.trisolarisserver.component.auth.PropertyAccess
import com.android.trisolarisserver.component.storage.RoomImageStorage
import com.android.trisolarisserver.controller.dto.room.RoomImageResponse
import com.android.trisolarisserver.controller.dto.room.RoomImageReorderRequest
import com.android.trisolarisserver.controller.dto.room.RoomImageTagUpdateRequest
import com.android.trisolarisserver.models.room.RoomImage
import com.android.trisolarisserver.models.room.RoomImageTag
import com.android.trisolarisserver.models.property.Role
import com.android.trisolarisserver.repo.room.RoomImageRepo
import com.android.trisolarisserver.repo.room.RoomImageTagRepo
import com.android.trisolarisserver.repo.room.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.transaction.annotation.Transactional
import org.springframework.web.bind.annotation.DeleteMapping
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.PutMapping
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.security.MessageDigest
import java.util.UUID
import org.springframework.web.bind.annotation.RequestBody
@RestController
@RequestMapping("/properties/{propertyId}/rooms/{roomId}/images")
class RoomImages(
private val propertyAccess: PropertyAccess,
private val roomRepo: RoomRepo,
private val roomImageRepo: RoomImageRepo,
private val roomImageTagRepo: RoomImageTagRepo,
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> {
ensureRoom(propertyId, roomId)
val images = roomImageRepo.findByRoomIdOrdered(roomId).toMutableList()
if (images.isEmpty()) return emptyList()
val missing = mutableListOf<RoomImage>()
val present = mutableListOf<RoomImage>()
for (img in images) {
val originalExists = Files.exists(Paths.get(img.originalPath))
if (!originalExists) {
missing.add(img)
try {
Files.deleteIfExists(Paths.get(img.thumbnailPath))
} catch (_: Exception) {
}
} else {
present.add(img)
}
}
if (missing.isNotEmpty()) {
roomImageRepo.deleteAll(missing)
}
return present.map { it.toResponse(publicBaseUrl) }
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
fun upload(
@PathVariable propertyId: UUID,
@PathVariable roomId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestParam("file") file: MultipartFile,
@RequestParam(required = false) tagIds: List<UUID>?
): RoomImageResponse {
val room = requireRoomAdmin(propertyId, roomId, principal)
if (file.isEmpty) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "File is empty")
}
val contentHash = sha256Hex(file.bytes)
if (roomImageRepo.existsByRoomIdAndContentHash(roomId, contentHash)) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Duplicate image for room")
}
val stored = try {
storage.store(propertyId, roomId, file)
} catch (ex: IllegalArgumentException) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, ex.message ?: "Invalid image")
}
val nextRoomSortOrder = roomImageRepo.findMaxRoomSortOrder(roomId) + 1
val nextRoomTypeSortOrder = roomImageRepo.findMaxRoomTypeSortOrder(room.roomType.code) + 1
val tags = resolveTags(tagIds)
val image = RoomImage(
property = room.property,
room = room,
originalPath = stored.originalPath,
thumbnailPath = stored.thumbnailPath,
contentType = stored.contentType,
sizeBytes = stored.sizeBytes,
contentHash = contentHash,
roomTypeCode = room.roomType.code,
tags = tags.toMutableSet(),
roomSortOrder = nextRoomSortOrder,
roomTypeSortOrder = nextRoomTypeSortOrder
)
return roomImageRepo.save(image).toResponse(publicBaseUrl)
}
@DeleteMapping("/{imageId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
@Transactional
fun delete(
@PathVariable propertyId: UUID,
@PathVariable roomId: UUID,
@PathVariable imageId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
) {
requireRoomAdmin(propertyId, roomId, principal)
val image = roomImageRepo.findByIdAndRoomIdAndPropertyId(imageId, roomId, propertyId)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Image not found")
val roomTypeCode = image.roomTypeCode
try {
Files.deleteIfExists(Paths.get(image.originalPath))
Files.deleteIfExists(Paths.get(image.thumbnailPath))
} catch (ex: Exception) {
throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to delete image files")
}
roomImageRepo.delete(image)
// Reorder room-specific sequence
val roomImages = roomImageRepo.findByRoomIdForReorder(roomId)
if (roomImages.isNotEmpty()) {
var order = 1
for (img in roomImages) {
if (img.roomSortOrder != order) {
img.roomSortOrder = order
}
order++
}
roomImageRepo.saveAll(roomImages)
}
// Reorder room-type sequence
if (!roomTypeCode.isNullOrBlank()) {
val roomTypeImages = roomImageRepo.findByRoomTypeCodeForReorder(roomTypeCode)
if (roomTypeImages.isNotEmpty()) {
var order = 1
for (img in roomTypeImages) {
if (img.roomTypeSortOrder != order) {
img.roomTypeSortOrder = order
}
order++
}
roomImageRepo.saveAll(roomTypeImages)
}
}
}
@PutMapping("/{imageId}/tags")
@ResponseStatus(HttpStatus.NO_CONTENT)
@Transactional
fun updateTags(
@PathVariable propertyId: UUID,
@PathVariable roomId: UUID,
@PathVariable imageId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: RoomImageTagUpdateRequest
) {
requireRoomAdmin(propertyId, roomId, principal)
val image = roomImageRepo.findByIdAndRoomIdAndPropertyId(imageId, roomId, propertyId)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Image not found")
val tags = resolveTags(request.tagIds.toList())
image.tags = tags.toMutableSet()
roomImageRepo.save(image)
}
@PutMapping("/reorder-room")
@ResponseStatus(HttpStatus.NO_CONTENT)
@Transactional
fun reorderRoomImages(
@PathVariable propertyId: UUID,
@PathVariable roomId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: RoomImageReorderRequest
) {
requireRoomAdmin(propertyId, roomId, principal)
if (request.imageIds.isEmpty()) {
return
}
val images = roomImageRepo.findByIdIn(request.imageIds).toMutableList()
if (images.size != request.imageIds.size) {
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Image not found")
}
if (images.any { it.room.id != roomId || it.property.id != propertyId }) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Images do not belong to room")
}
val orderMap = request.imageIds.withIndex().associate { it.value to it.index + 1 }
for (img in images) {
img.roomSortOrder = orderMap[img.id] ?: img.roomSortOrder
}
roomImageRepo.saveAll(images)
}
@PutMapping("/reorder-room-type")
@ResponseStatus(HttpStatus.NO_CONTENT)
@Transactional
fun reorderRoomTypeImages(
@PathVariable propertyId: UUID,
@PathVariable roomId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: RoomImageReorderRequest
) {
val room = requireRoomAdmin(propertyId, roomId, principal)
if (request.imageIds.isEmpty()) {
return
}
val images = roomImageRepo.findByIdIn(request.imageIds).toMutableList()
if (images.size != request.imageIds.size) {
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Image not found")
}
val roomTypeCode = room.roomType.code
if (images.any { it.roomTypeCode != roomTypeCode }) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Images do not belong to room type")
}
if (images.any { it.property.id != propertyId }) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Images do not belong to property")
}
val orderMap = request.imageIds.withIndex().associate { it.value to it.index + 1 }
for (img in images) {
img.roomTypeSortOrder = orderMap[img.id] ?: img.roomTypeSortOrder
}
roomImageRepo.saveAll(images)
}
@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> {
if (principal != null) {
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 requireRoomAdmin(propertyId: UUID, roomId: UUID, principal: MyPrincipal?): com.android.trisolarisserver.models.room.Room {
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER)
return ensureRoom(propertyId, roomId)
}
private fun resolveTags(tagIds: List<UUID>?): Set<RoomImageTag> {
if (tagIds.isNullOrEmpty()) {
return emptySet()
}
val tags = roomImageTagRepo.findByIdIn(tagIds.toSet())
if (tags.size != tagIds.size) {
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Tag not found")
}
return tags.toSet()
}
private fun sha256Hex(bytes: ByteArray): String {
val md = MessageDigest.getInstance("SHA-256")
val hash = md.digest(bytes)
val sb = StringBuilder(hash.size * 2)
for (b in hash) {
sb.append(String.format("%02x", b))
}
return sb.toString()
}
}
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!!,
roomTypeCode = roomTypeCode,
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,
tags = tags.map { it.toResponse() }.toSet(),
roomSortOrder = roomSortOrder,
roomTypeSortOrder = roomTypeSortOrder,
createdAt = createdAt.toString()
)
}
private fun RoomImageTag.toResponse(): com.android.trisolarisserver.controller.dto.room.RoomImageTagResponse {
val id = id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Tag id missing")
return com.android.trisolarisserver.controller.dto.room.RoomImageTagResponse(
id = id,
name = name
)
}

View File

@@ -1,146 +0,0 @@
package com.android.trisolarisserver.controller.room
import com.android.trisolarisserver.controller.common.parseOffset
import com.android.trisolarisserver.controller.common.requireMember
import com.android.trisolarisserver.controller.common.requireRoomStayForProperty
import com.android.trisolarisserver.component.auth.PropertyAccess
import com.android.trisolarisserver.controller.dto.booking.RoomStayVoidRequest
import com.android.trisolarisserver.controller.dto.room.ActiveRoomStayResponse
import com.android.trisolarisserver.models.property.Role
import com.android.trisolarisserver.models.room.RoomStayAuditLog
import com.android.trisolarisserver.models.room.RoomStay
import com.android.trisolarisserver.repo.booking.BookingRepo
import com.android.trisolarisserver.repo.booking.PaymentRepo
import com.android.trisolarisserver.repo.property.AppUserRepo
import com.android.trisolarisserver.repo.property.PropertyUserRepo
import com.android.trisolarisserver.repo.room.RoomStayAuditLogRepo
import com.android.trisolarisserver.repo.room.RoomStayRepo
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.RestController
import org.springframework.web.server.ResponseStatusException
import java.time.OffsetDateTime
import java.util.UUID
@RestController
class RoomStays(
private val propertyAccess: PropertyAccess,
private val propertyUserRepo: PropertyUserRepo,
private val appUserRepo: AppUserRepo,
private val bookingRepo: BookingRepo,
private val paymentRepo: PaymentRepo,
private val roomStayAuditLogRepo: RoomStayAuditLogRepo,
private val roomStayRepo: RoomStayRepo
) {
@GetMapping("/properties/{propertyId}/room-stays/active")
fun listActiveRoomStays(
@PathVariable propertyId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
): List<ActiveRoomStayResponse> {
if (principal == null) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
}
propertyAccess.requireMember(propertyId, principal.userId)
val roles = propertyUserRepo.findRolesByPropertyAndUser(propertyId, principal.userId)
if (isAgentOnly(roles)) {
throw ResponseStatusException(HttpStatus.FORBIDDEN, "Agents cannot view active stays")
}
return roomStayRepo.findActiveByPropertyIdWithDetails(propertyId).map { stay ->
val booking = stay.booking
val guest = booking.primaryGuest
val room = stay.room
val roomType = room.roomType
ActiveRoomStayResponse(
roomStayId = stay.id!!,
bookingId = booking.id!!,
guestId = guest?.id,
guestName = guest?.name,
guestPhone = guest?.phoneE164,
roomId = room.id!!,
roomNumber = room.roomNumber,
roomTypeName = roomType.name,
fromAt = stay.fromAt.toString(),
checkinAt = booking.checkinAt?.toString(),
expectedCheckoutAt = booking.expectedCheckoutAt?.toString(),
nightlyRate = stay.nightlyRate
)
}
}
@PostMapping("/properties/{propertyId}/room-stays/{roomStayId}/void")
fun voidRoomStay(
@PathVariable propertyId: UUID,
@PathVariable roomStayId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: RoomStayVoidRequest
) {
val actor = requireMember(propertyAccess, propertyId, principal)
val stay = requireRoomStayForProperty(roomStayRepo, propertyId, roomStayId)
if (stay.isVoided) return
if (stay.toAt != null) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Cannot void checked-out room stay")
}
val roles = propertyUserRepo.findRolesByPropertyAndUser(propertyId, actor.userId)
val hasPrivilegedRole = roles.contains(Role.ADMIN) || roles.contains(Role.MANAGER)
val hasStaffRole = roles.contains(Role.STAFF)
if (!hasPrivilegedRole && !hasStaffRole) {
throw ResponseStatusException(HttpStatus.FORBIDDEN, "Missing role")
}
if (!hasPrivilegedRole && paymentRepo.existsByBookingId(stay.booking.id!!)) {
throw ResponseStatusException(HttpStatus.FORBIDDEN, "Cannot void stay after first payment")
}
val appUser = appUserRepo.findById(actor.userId).orElse(null)
val oldToAt = stay.toAt
stay.isVoided = true
stay.toAt = OffsetDateTime.now()
roomStayRepo.save(stay)
roomStayAuditLogRepo.save(
RoomStayAuditLog(
property = stay.property,
booking = stay.booking,
roomStay = stay,
action = "VOID",
oldToAt = oldToAt,
newToAt = stay.toAt,
oldIsVoided = false,
newIsVoided = true,
reason = request.reason,
actor = appUser
)
)
val booking = bookingRepo.findById(stay.booking.id!!).orElse(null)
if (booking != null && booking.status == com.android.trisolarisserver.models.booking.BookingStatus.CHECKED_IN) {
val active = roomStayRepo.findActiveByBookingId(booking.id!!)
if (active.isEmpty()) {
booking.status = com.android.trisolarisserver.models.booking.BookingStatus.CHECKED_OUT
booking.checkoutAt = OffsetDateTime.now()
booking.updatedAt = OffsetDateTime.now()
bookingRepo.save(booking)
}
}
}
private fun isAgentOnly(roles: Set<Role>): Boolean {
if (!roles.contains(Role.AGENT)) return false
val privileged = setOf(
Role.ADMIN,
Role.MANAGER,
Role.STAFF,
Role.HOUSEKEEPING,
Role.FINANCE,
Role.SUPERVISOR,
Role.GUIDE
)
return roles.none { it in privileged }
}
}

View File

@@ -1,84 +0,0 @@
package com.android.trisolarisserver.controller.room
import com.android.trisolarisserver.controller.dto.room.RoomImageTagResponse
import com.android.trisolarisserver.controller.dto.room.RoomImageResponse
import com.android.trisolarisserver.models.room.RoomImage
import com.android.trisolarisserver.models.room.RoomImageTag
import com.android.trisolarisserver.repo.room.RoomImageRepo
import com.android.trisolarisserver.repo.room.RoomTypeRepo
import org.springframework.http.HttpStatus
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}/room-types/{roomTypeCode}/images")
class RoomTypeImages(
private val roomTypeRepo: RoomTypeRepo,
private val roomImageRepo: RoomImageRepo,
@org.springframework.beans.factory.annotation.Value("\${storage.rooms.publicBaseUrl}")
private val publicBaseUrl: String
) {
@GetMapping
fun listByRoomType(
@PathVariable propertyId: UUID,
@PathVariable roomTypeCode: String
): List<RoomImageResponse> {
val roomType = roomTypeRepo.findByPropertyIdAndCodeIgnoreCase(propertyId, roomTypeCode)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room type not found")
val images = roomImageRepo.findByPropertyIdAndRoomTypeCodeOrdered(propertyId, roomType.code).toMutableList()
if (images.isEmpty()) return emptyList()
val missing = mutableListOf<RoomImage>()
val present = mutableListOf<RoomImage>()
for (img in images) {
val originalExists = Files.exists(Paths.get(img.originalPath))
if (!originalExists) {
missing.add(img)
try {
Files.deleteIfExists(Paths.get(img.thumbnailPath))
} catch (_: Exception) {
}
} else {
present.add(img)
}
}
if (missing.isNotEmpty()) {
roomImageRepo.deleteAll(missing)
}
return present.map { it.toResponse(publicBaseUrl) }
}
}
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!!,
roomTypeCode = roomTypeCode,
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,
tags = tags.map { it.toResponse() }.toSet(),
roomSortOrder = roomSortOrder,
roomTypeSortOrder = roomTypeSortOrder,
createdAt = createdAt.toString()
)
}
private fun RoomImageTag.toResponse(): com.android.trisolarisserver.controller.dto.room.RoomImageTagResponse {
val id = id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Tag id missing")
return com.android.trisolarisserver.controller.dto.room.RoomImageTagResponse(
id = id,
name = name
)
}

View File

@@ -1,233 +0,0 @@
package com.android.trisolarisserver.controller.room
import com.android.trisolarisserver.controller.common.parseDate
import com.android.trisolarisserver.controller.common.requireMember
import com.android.trisolarisserver.controller.common.requirePrincipal
import com.android.trisolarisserver.controller.dto.room.AmenityResponse
import com.android.trisolarisserver.component.auth.PropertyAccess
import com.android.trisolarisserver.controller.dto.rate.RateResolveResponse
import com.android.trisolarisserver.controller.dto.room.RoomTypeResponse
import com.android.trisolarisserver.controller.dto.room.RoomTypeUpsertRequest
import com.android.trisolarisserver.repo.property.PropertyRepo
import com.android.trisolarisserver.repo.rate.RateCalendarRepo
import com.android.trisolarisserver.repo.rate.RatePlanRepo
import com.android.trisolarisserver.repo.room.RoomAmenityRepo
import com.android.trisolarisserver.repo.room.RoomRepo
import com.android.trisolarisserver.repo.room.RoomTypeRepo
import com.android.trisolarisserver.models.property.Role
import com.android.trisolarisserver.models.room.RoomAmenity
import com.android.trisolarisserver.models.room.RoomType
import com.android.trisolarisserver.security.MyPrincipal
import org.springframework.http.HttpStatus
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.DeleteMapping
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.PutMapping
import org.springframework.web.bind.annotation.RequestBody
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.server.ResponseStatusException
import java.util.UUID
@RestController
@RequestMapping("/properties/{propertyId}/room-types")
class RoomTypes(
private val propertyAccess: PropertyAccess,
private val roomTypeRepo: RoomTypeRepo,
private val roomAmenityRepo: RoomAmenityRepo,
private val roomRepo: RoomRepo,
private val propertyRepo: PropertyRepo,
private val ratePlanRepo: RatePlanRepo,
private val rateCalendarRepo: RateCalendarRepo
) {
@GetMapping
fun listRoomTypes(
@PathVariable propertyId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
): List<RoomTypeResponse> {
if (principal != null) {
propertyAccess.requireMember(propertyId, principal.userId)
}
return roomTypeRepo.findByPropertyIdOrderByCode(propertyId).map { it.toResponse() }
}
@GetMapping("/{roomTypeCode}/rate")
fun resolveRate(
@PathVariable propertyId: UUID,
@PathVariable roomTypeCode: String,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestParam date: String,
@RequestParam(required = false) ratePlanCode: String?
): RateResolveResponse {
if (principal != null) {
propertyAccess.requireMember(propertyId, principal.userId)
}
val property = propertyRepo.findById(propertyId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
}
val roomType = roomTypeRepo.findByPropertyIdAndCodeIgnoreCase(propertyId, roomTypeCode)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room type not found")
val rateDate = parseDate(date, "Invalid date")
if (!ratePlanCode.isNullOrBlank()) {
val plan = ratePlanRepo.findByPropertyIdOrderByCode(propertyId)
.firstOrNull { it.code.equals(ratePlanCode, ignoreCase = true) }
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Rate plan not found")
if (plan.roomType.id != roomType.id) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Rate plan not for room type")
}
val override = rateCalendarRepo.findByRatePlanIdAndRateDate(plan.id!!, rateDate)
val rate = override?.rate ?: plan.baseRate
return RateResolveResponse(
roomTypeCode = roomType.code,
rateDate = rateDate,
rate = rate,
currency = plan.currency,
ratePlanCode = plan.code
)
}
val rate = roomType.defaultRate
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Default rate not set")
return RateResolveResponse(
roomTypeCode = roomType.code,
rateDate = rateDate,
rate = rate,
currency = property.currency
)
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
fun createRoomType(
@PathVariable propertyId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: RoomTypeUpsertRequest
): RoomTypeResponse {
requirePrincipal(principal)
propertyAccess.requireMember(propertyId, principal!!.userId)
propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN, Role.MANAGER)
if (roomTypeRepo.existsByPropertyIdAndCode(propertyId, request.code)) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Room type code already exists for property")
}
val property = propertyRepo.findById(propertyId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
}
val roomType = RoomType(
property = property,
code = request.code,
name = request.name,
baseOccupancy = request.baseOccupancy ?: 2,
maxOccupancy = request.maxOccupancy ?: 3,
sqFeet = request.sqFeet,
bathroomSqFeet = request.bathroomSqFeet,
defaultRate = request.defaultRate,
active = request.active ?: true,
otaAliases = request.otaAliases?.toMutableSet() ?: mutableSetOf()
)
if (request.amenityIds != null) {
roomType.amenities = resolveAmenities(request.amenityIds)
}
return roomTypeRepo.save(roomType).toResponse()
}
private fun resolveAmenities(ids: Set<UUID>): MutableSet<RoomAmenity> {
if (ids.isEmpty()) {
return mutableSetOf()
}
val amenities = roomAmenityRepo.findByIdIn(ids)
if (amenities.size != ids.size) {
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Amenity not found")
}
return amenities.toMutableSet()
}
@PutMapping("/{roomTypeId}")
fun updateRoomType(
@PathVariable propertyId: UUID,
@PathVariable roomTypeId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: RoomTypeUpsertRequest
): RoomTypeResponse {
requirePrincipal(principal)
propertyAccess.requireMember(propertyId, principal!!.userId)
propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN, Role.MANAGER)
val roomType = roomTypeRepo.findByIdAndPropertyId(roomTypeId, propertyId)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room type not found")
if (roomTypeRepo.existsByPropertyIdAndCodeAndIdNot(propertyId, request.code, roomTypeId)) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Room type code already exists for property")
}
roomType.code = request.code
roomType.name = request.name
roomType.baseOccupancy = request.baseOccupancy ?: roomType.baseOccupancy
roomType.maxOccupancy = request.maxOccupancy ?: roomType.maxOccupancy
roomType.sqFeet = request.sqFeet ?: roomType.sqFeet
roomType.bathroomSqFeet = request.bathroomSqFeet ?: roomType.bathroomSqFeet
roomType.defaultRate = request.defaultRate ?: roomType.defaultRate
roomType.active = request.active ?: roomType.active
if (request.otaAliases != null) {
roomType.otaAliases = request.otaAliases.toMutableSet()
}
if (request.amenityIds != null) {
roomType.amenities = resolveAmenities(request.amenityIds)
}
return roomTypeRepo.save(roomType).toResponse()
}
@DeleteMapping("/{roomTypeId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun deleteRoomType(
@PathVariable propertyId: UUID,
@PathVariable roomTypeId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
) {
requirePrincipal(principal)
propertyAccess.requireMember(propertyId, principal!!.userId)
propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN, Role.MANAGER)
val roomType = roomTypeRepo.findByIdAndPropertyId(roomTypeId, propertyId)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room type not found")
roomType.active = false
roomTypeRepo.save(roomType)
}
}
private fun RoomType.toResponse(): RoomTypeResponse {
val id = id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Room type id missing")
val propertyId = property.id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Property id missing")
return RoomTypeResponse(
id = id,
propertyId = propertyId,
code = code,
name = name,
baseOccupancy = baseOccupancy,
maxOccupancy = maxOccupancy,
sqFeet = sqFeet,
bathroomSqFeet = bathroomSqFeet,
defaultRate = defaultRate,
active = active,
otaAliases = otaAliases.toSet(),
amenities = amenities.map { it.toResponse() }.toSet()
)
}
private fun RoomAmenity.toResponse(): com.android.trisolarisserver.controller.dto.room.AmenityResponse {
val id = id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Amenity id missing")
return com.android.trisolarisserver.controller.dto.room.AmenityResponse(
id = id,
name = name,
category = category,
iconKey = iconKey
)
}

Some files were not shown because too many files have changed in this diff Show More