Compare commits
2 Commits
master
...
4998701f84
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4998701f84 | ||
|
|
dfe44927ef |
@@ -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
114
AGENTS.md
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
2353
razoryPayDocs.txt
2353
razoryPayDocs.txt
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
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(
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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(", ")
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,26 +188,13 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
266
src/main/kotlin/com/android/trisolarisserver/controller/Rooms.kt
Normal file
266
src/main/kotlin/com/android/trisolarisserver/controller/Rooms.kt
Normal 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
|
||||
)
|
||||
}
|
||||
@@ -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(
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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?
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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,
|
||||
@@ -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,
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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?
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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?
|
||||
)
|
||||
@@ -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?
|
||||
)
|
||||
@@ -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?
|
||||
)
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user