Compare commits
2 Commits
master
...
4998701f84
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4998701f84 | ||
|
|
dfe44927ef |
@@ -1,32 +1,35 @@
|
|||||||
name: build-and-deploy
|
name: build
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ "master" ]
|
branches: [ "**" ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ "**" ]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-deploy:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up JDK 21
|
- name: Set up JDK 17
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "21"
|
java-version: "17"
|
||||||
cache: gradle
|
cache: gradle
|
||||||
|
|
||||||
- name: Build (skip tests)
|
- name: Build (verbose)
|
||||||
env:
|
env:
|
||||||
GRADLE_OPTS: "-Dorg.gradle.internal.http.connectionTimeout=60000 -Dorg.gradle.internal.http.socketTimeout=60000 -Dorg.gradle.workers.max=6"
|
GRADLE_OPTS: "-Dorg.gradle.internal.http.connectionTimeout=60000 -Dorg.gradle.internal.http.socketTimeout=60000"
|
||||||
run: ./gradlew build -x test --info --stacktrace
|
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: |
|
run: |
|
||||||
set -e
|
curl -sS -X POST http://127.0.0.1:9000/hooks/deploy-trisolarisserver
|
||||||
mkdir -p /opt/deploy/TrisolarisServer/build/libs
|
|
||||||
cp -f build/libs/*.jar /opt/deploy/TrisolarisServer/build/libs/
|
|
||||||
sudo systemctl restart TrisolarisServer.service
|
|
||||||
|
|||||||
114
AGENTS.md
114
AGENTS.md
@@ -22,7 +22,7 @@ Core principles
|
|||||||
- Room availability by room number; toAt=null means occupied.
|
- Room availability by room number; toAt=null means occupied.
|
||||||
- Room change = close old RoomStay + open new one.
|
- Room change = close old RoomStay + open new one.
|
||||||
- Multi-property: every domain object scoped to property_id.
|
- 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
|
Immutable rules
|
||||||
- Use Kotlin only; no microservices.
|
- Use Kotlin only; no microservices.
|
||||||
@@ -40,33 +40,25 @@ Repository
|
|||||||
- Root: /home/androidlover5842/IdeaProjects/TrisolarisServer
|
- Root: /home/androidlover5842/IdeaProjects/TrisolarisServer
|
||||||
- Entry: src/main/kotlin/com/android/trisolarisserver/TrisolarisServerApplication.kt
|
- Entry: src/main/kotlin/com/android/trisolarisserver/TrisolarisServerApplication.kt
|
||||||
- Scheduling enabled (@EnableScheduling)
|
- 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
|
Security/Auth
|
||||||
- Firebase Admin auth for every request; Firebase UID required.
|
- Firebase Admin auth for every request; Firebase UID required.
|
||||||
- /auth/verify and /auth/me.
|
- /auth/verify and /auth/me.
|
||||||
|
|
||||||
Domain entities
|
Domain entities
|
||||||
|
- Organization: name, emailAliases, allowedTransportModes.
|
||||||
- Property: code, name, addressText, emailAddresses, otaAliases, allowedTransportModes.
|
- Property: code, name, addressText, emailAddresses, otaAliases, allowedTransportModes.
|
||||||
- AppUser (global, superAdmin), PropertyUser (roles per property).
|
- AppUser, PropertyUser (roles per property).
|
||||||
- RoomType: code/name/occupancy + otaAliases + defaultRate.
|
- RoomType: code/name/occupancy + otaAliases.
|
||||||
- Room: roomNumber, floor, hasNfc, active, maintenance, notes.
|
- Room: roomNumber, floor, hasNfc, active, maintenance, notes.
|
||||||
- Booking: status, expected check-in/out, emailAuditPdfUrl, transportMode.
|
- Booking: status, expected check-in/out, emailAuditPdfUrl, transportMode, transportVehicleNumber.
|
||||||
- Guest (property-scoped).
|
- Guest (org-scoped).
|
||||||
- RoomStay (rate fields stored on stay).
|
- RoomStay.
|
||||||
- RoomStayChange (idempotent room move).
|
- RoomStayChange (idempotent room move).
|
||||||
- IssuedCard (cardId, cardIndex, issuedAt, expiresAt, issuedBy, revokedAt).
|
- IssuedCard (cardId, cardIndex, issuedAt, expiresAt, issuedBy, revokedAt).
|
||||||
- PropertyCardCounter (per-property cardIndex counter).
|
- PropertyCardCounter (per-property cardIndex counter).
|
||||||
- RatePlan + RateCalendar.
|
|
||||||
- Payment (ledger).
|
|
||||||
- GuestDocument (files + AI-extracted json).
|
- GuestDocument (files + AI-extracted json).
|
||||||
- GuestVehicle (property-scoped vehicle numbers).
|
- GuestVehicle (org-scoped vehicle numbers).
|
||||||
- InboundEmail (audit PDF + raw EML, extracted json, status).
|
- InboundEmail (audit PDF + raw EML, extracted json, status).
|
||||||
- RoomImage (original + thumbnail).
|
- RoomImage (original + thumbnail).
|
||||||
|
|
||||||
@@ -76,10 +68,14 @@ Auth
|
|||||||
- /auth/verify
|
- /auth/verify
|
||||||
- /auth/me
|
- /auth/me
|
||||||
|
|
||||||
Properties / Users
|
Organizations / Properties / Users
|
||||||
- POST /properties (creator becomes ADMIN on that property)
|
- POST /orgs
|
||||||
- GET /properties (super admin gets all; others get memberships)
|
- GET /orgs/{orgId}
|
||||||
|
- POST /orgs/{orgId}/properties
|
||||||
|
- GET /orgs/{orgId}/properties
|
||||||
- PUT /properties/{propertyId}
|
- PUT /properties/{propertyId}
|
||||||
|
- GET /orgs/{orgId}/users
|
||||||
|
- POST /orgs/{orgId}/users (removed; users created by app)
|
||||||
- GET /properties/{propertyId}/users
|
- GET /properties/{propertyId}/users
|
||||||
- PUT /properties/{propertyId}/users/{userId}/roles
|
- PUT /properties/{propertyId}/users/{userId}/roles
|
||||||
- DELETE /properties/{propertyId}/users/{userId} (ADMIN only)
|
- DELETE /properties/{propertyId}/users/{userId} (ADMIN only)
|
||||||
@@ -90,74 +86,39 @@ Rooms / inventory
|
|||||||
- /properties/{propertyId}/rooms/board/stream (SSE)
|
- /properties/{propertyId}/rooms/board/stream (SSE)
|
||||||
- /properties/{propertyId}/rooms/availability
|
- /properties/{propertyId}/rooms/availability
|
||||||
- /properties/{propertyId}/rooms/availability-range?from=YYYY-MM-DD&to=YYYY-MM-DD
|
- /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
|
Room types
|
||||||
- POST /properties/{propertyId}/room-types
|
- POST /properties/{propertyId}/room-types
|
||||||
- GET /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}
|
- PUT /properties/{propertyId}/room-types/{roomTypeId}
|
||||||
- DELETE /properties/{propertyId}/room-types/{roomTypeId}
|
- DELETE /properties/{propertyId}/room-types/{roomTypeId}
|
||||||
|
|
||||||
Properties
|
Properties / Orgs
|
||||||
- Property create/update accepts addressText, otaAliases, emailAddresses, allowedTransportModes.
|
- Property create/update accepts addressText, otaAliases, emailAddresses, allowedTransportModes.
|
||||||
|
- Org create/get returns emailAliases + allowedTransportModes.
|
||||||
|
|
||||||
Booking flow
|
Booking flow
|
||||||
- POST /properties/{propertyId}/bookings (create booking)
|
- /properties/{propertyId}/bookings/{bookingId}/check-in (creates RoomStay rows)
|
||||||
- /properties/{propertyId}/bookings/{bookingId}/check-in/bulk (creates RoomStay rows with per-stay rates)
|
|
||||||
- /properties/{propertyId}/bookings/{bookingId}/check-out (closes RoomStay)
|
- /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}/cancel
|
||||||
- /properties/{propertyId}/bookings/{bookingId}/no-show
|
- /properties/{propertyId}/bookings/{bookingId}/no-show
|
||||||
- /properties/{propertyId}/bookings/{bookingId}/room-requests (room-type quantity reservation)
|
- /properties/{propertyId}/bookings/{bookingId}/room-stays (pre-assign RoomStay with date range)
|
||||||
- /properties/{propertyId}/bookings/{bookingId}/room-requests/{requestId} (cancel reservation)
|
- /properties/{propertyId}/room-stays/{roomStayId}/change-room (idempotent via RoomStayChange)
|
||||||
- /properties/{propertyId}/room-stays/{roomStayId}/void (soft-void active stay)
|
|
||||||
- /properties/{propertyId}/cancellation-policy (get/update policy)
|
|
||||||
|
|
||||||
Card issuing
|
Card issuing
|
||||||
- /properties/{propertyId}/room-stays/{roomStayId}/cards/prepare -> returns cardIndex + sector0 payload
|
- /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 -> store issued card
|
||||||
- /properties/{propertyId}/room-stays/{roomStayId}/cards (list)
|
- /properties/{propertyId}/room-stays/{roomStayId}/cards (list)
|
||||||
- /properties/{propertyId}/room-stays/cards/{cardIndex}/revoke (ADMIN; MANAGER allowed only for temp cards)
|
- /properties/{propertyId}/room-stays/cards/{cardId}/revoke (ADMIN only)
|
||||||
- Temp cards (room-only, 7 min expiry):
|
|
||||||
- POST /properties/{propertyId}/rooms/{roomId}/cards/prepare-temp
|
|
||||||
- POST /properties/{propertyId}/rooms/{roomId}/cards/temp
|
|
||||||
|
|
||||||
Guest APIs
|
Guest APIs
|
||||||
- POST /properties/{propertyId}/guests
|
|
||||||
- /properties/{propertyId}/guests/search?phone=... or ?vehicleNumber=...
|
- /properties/{propertyId}/guests/search?phone=... or ?vehicleNumber=...
|
||||||
- /properties/{propertyId}/guests/{guestId}/vehicles (add vehicle)
|
- /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
|
Guest documents
|
||||||
- /properties/{propertyId}/guests/{guestId}/documents (upload/list)
|
- /properties/{propertyId}/guests/{guestId}/documents (upload/list)
|
||||||
- /properties/{propertyId}/guests/{guestId}/documents/{documentId}/file
|
- /properties/{propertyId}/guests/{guestId}/documents/{documentId}/file
|
||||||
- AI extraction with strict system prompt.
|
- 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
|
Room images
|
||||||
- /properties/{propertyId}/rooms/{roomId}/images (upload/list)
|
- /properties/{propertyId}/rooms/{roomId}/images (upload/list)
|
||||||
@@ -165,7 +126,7 @@ Room images
|
|||||||
- Thumbnails generated (320px).
|
- Thumbnails generated (320px).
|
||||||
|
|
||||||
Transport modes
|
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
|
Inbound email ingestion
|
||||||
- IMAP poller (1 min) with enable flag.
|
- IMAP poller (1 min) with enable flag.
|
||||||
@@ -192,36 +153,5 @@ Config
|
|||||||
|
|
||||||
Notes / constraints
|
Notes / constraints
|
||||||
- Users are created by app; API only manages roles.
|
- 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.
|
- Admin can assign ADMIN/MANAGER/STAFF/AGENT; Manager can assign STAFF/AGENT.
|
||||||
- Agents can only see free rooms.
|
- 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 {
|
java {
|
||||||
toolchain {
|
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"
|
rootProject.name = "TrisolarisServer"
|
||||||
|
|||||||
@@ -3,13 +3,11 @@ package com.android.trisolarisserver
|
|||||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||||
import org.springframework.boot.runApplication
|
import org.springframework.boot.runApplication
|
||||||
import org.springframework.scheduling.annotation.EnableScheduling
|
import org.springframework.scheduling.annotation.EnableScheduling
|
||||||
import java.util.TimeZone
|
|
||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
@EnableScheduling
|
@EnableScheduling
|
||||||
class TrisolarisServerApplication
|
class TrisolarisServerApplication
|
||||||
|
|
||||||
fun main(args: Array<String>) {
|
fun main(args: Array<String>) {
|
||||||
TimeZone.setDefault(TimeZone.getTimeZone("Asia/Kolkata"))
|
|
||||||
runApplication<TrisolarisServerApplication>(*args)
|
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.beans.factory.annotation.Value
|
||||||
import org.springframework.stereotype.Component
|
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.beans.factory.annotation.Value
|
||||||
import org.springframework.stereotype.Component
|
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.PDDocument
|
||||||
import org.apache.pdfbox.pdmodel.PDPage
|
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.beans.factory.annotation.Value
|
||||||
import org.springframework.stereotype.Component
|
import org.springframework.stereotype.Component
|
||||||
import java.nio.file.Files
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.Path
|
||||||
import java.nio.file.Paths
|
import java.nio.file.Paths
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
@@ -111,4 +112,12 @@ class EmailStorage(
|
|||||||
atomicMove(tmp, path)
|
atomicMove(tmp, path)
|
||||||
return path.toString()
|
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 com.fasterxml.jackson.databind.ObjectMapper
|
||||||
import org.springframework.beans.factory.annotation.Value
|
import org.springframework.beans.factory.annotation.Value
|
||||||
@@ -13,34 +13,17 @@ class LlamaClient(
|
|||||||
private val restTemplate: RestTemplate,
|
private val restTemplate: RestTemplate,
|
||||||
private val objectMapper: ObjectMapper,
|
private val objectMapper: ObjectMapper,
|
||||||
@Value("\${ai.llama.baseUrl}")
|
@Value("\${ai.llama.baseUrl}")
|
||||||
private val baseUrl: String,
|
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 systemPrompt =
|
private val systemPrompt =
|
||||||
"Read extremely carefully. Look only at visible text. " +
|
"Look only at visible text. " +
|
||||||
"Return the exact text you can read verbatim. " +
|
"Return the exact text you can read verbatim. " +
|
||||||
"If the text is unclear, partial, or inferred, return NOT CLEARLY VISIBLE. " +
|
"If the text is unclear, partial, or inferred, return NOT CLEARLY VISIBLE. " +
|
||||||
"Do not guess. Do not explain."
|
"Do not guess. Do not explain."
|
||||||
|
|
||||||
fun ask(imageUrl: String, question: String): String {
|
fun ask(imageUrl: String, question: String): String {
|
||||||
val payload = mapOf(
|
val payload = mapOf(
|
||||||
"model" to model,
|
"model" to "qwen",
|
||||||
"temperature" to temperature,
|
|
||||||
"top_p" to topP,
|
|
||||||
"min_p" to minP,
|
|
||||||
"repeat_penalty" to repeatPenalty,
|
|
||||||
"top_k" to topK,
|
|
||||||
"messages" to listOf(
|
"messages" to listOf(
|
||||||
mapOf(
|
mapOf(
|
||||||
"role" to "system",
|
"role" to "system",
|
||||||
@@ -55,45 +38,18 @@ class LlamaClient(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return post(payload)
|
val headers = HttpHeaders()
|
||||||
}
|
headers.contentType = MediaType.APPLICATION_JSON
|
||||||
|
val entity = HttpEntity(payload, headers)
|
||||||
fun askWithOcr(imageUrl: String, ocrText: String, question: String): String {
|
val response = restTemplate.postForEntity(baseUrl, entity, String::class.java)
|
||||||
val payload = mapOf(
|
val body = response.body ?: return ""
|
||||||
"model" to model,
|
val node = objectMapper.readTree(body)
|
||||||
"temperature" to temperature,
|
return node.path("choices").path(0).path("message").path("content").asText()
|
||||||
"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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun askText(content: String, question: String): String {
|
fun askText(content: String, question: String): String {
|
||||||
val payload = mapOf(
|
val payload = mapOf(
|
||||||
"model" to model,
|
"model" to "qwen",
|
||||||
"temperature" to temperature,
|
|
||||||
"top_p" to topP,
|
|
||||||
"min_p" to minP,
|
|
||||||
"repeat_penalty" to repeatPenalty,
|
|
||||||
"top_k" to topK,
|
|
||||||
"messages" to listOf(
|
"messages" to listOf(
|
||||||
mapOf(
|
mapOf(
|
||||||
"role" to "system",
|
"role" to "system",
|
||||||
@@ -105,10 +61,6 @@ class LlamaClient(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return post(payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun post(payload: Map<String, Any>): String {
|
|
||||||
val headers = HttpHeaders()
|
val headers = HttpHeaders()
|
||||||
headers.contentType = MediaType.APPLICATION_JSON
|
headers.contentType = MediaType.APPLICATION_JSON
|
||||||
val entity = HttpEntity(payload, headers)
|
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.beans.factory.annotation.Value
|
||||||
import org.springframework.stereotype.Component
|
import org.springframework.stereotype.Component
|
||||||
@@ -6,6 +6,7 @@ import org.springframework.web.multipart.MultipartFile
|
|||||||
import java.awt.image.BufferedImage
|
import java.awt.image.BufferedImage
|
||||||
import java.io.ByteArrayInputStream
|
import java.io.ByteArrayInputStream
|
||||||
import java.nio.file.Files
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.Path
|
||||||
import java.nio.file.Paths
|
import java.nio.file.Paths
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
@@ -16,18 +17,6 @@ class RoomImageStorage(
|
|||||||
@Value("\${storage.rooms.root:/home/androidlover5842/docs/rooms}")
|
@Value("\${storage.rooms.root:/home/androidlover5842/docs/rooms}")
|
||||||
private val rootPath: String
|
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 {
|
fun store(propertyId: UUID, roomId: UUID, file: MultipartFile): StoredRoomImage {
|
||||||
val contentType = file.contentType ?: ""
|
val contentType = file.contentType ?: ""
|
||||||
if (!contentType.startsWith("image/")) {
|
if (!contentType.startsWith("image/")) {
|
||||||
@@ -38,11 +27,7 @@ class RoomImageStorage(
|
|||||||
val ext = extensionFor(contentType, originalName)
|
val ext = extensionFor(contentType, originalName)
|
||||||
|
|
||||||
val dir = Paths.get(rootPath, propertyId.toString(), roomId.toString())
|
val dir = Paths.get(rootPath, propertyId.toString(), roomId.toString())
|
||||||
try {
|
|
||||||
Files.createDirectories(dir)
|
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 base = UUID.randomUUID().toString() + "_" + OffsetDateTime.now().toEpochSecond()
|
||||||
val originalPath = dir.resolve("$base.$ext")
|
val originalPath = dir.resolve("$base.$ext")
|
||||||
val originalTmp = dir.resolve("$base.$ext.tmp")
|
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(
|
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.Bean
|
||||||
import org.springframework.context.annotation.Configuration
|
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.databind.ObjectMapper
|
||||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
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
|
package com.android.trisolarisserver.controller
|
||||||
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.component.auth.PropertyAccess
|
import com.android.trisolarisserver.component.PropertyAccess
|
||||||
import com.android.trisolarisserver.controller.dto.guest.GuestRatingCreateRequest
|
import com.android.trisolarisserver.controller.dto.GuestRatingCreateRequest
|
||||||
import com.android.trisolarisserver.controller.dto.guest.GuestRatingResponse
|
import com.android.trisolarisserver.controller.dto.GuestRatingResponse
|
||||||
import com.android.trisolarisserver.repo.booking.BookingRepo
|
import com.android.trisolarisserver.db.repo.BookingRepo
|
||||||
import com.android.trisolarisserver.repo.guest.GuestRatingRepo
|
import com.android.trisolarisserver.db.repo.GuestRatingRepo
|
||||||
import com.android.trisolarisserver.repo.guest.GuestRepo
|
import com.android.trisolarisserver.db.repo.GuestRepo
|
||||||
import com.android.trisolarisserver.repo.property.AppUserRepo
|
import com.android.trisolarisserver.repo.AppUserRepo
|
||||||
import com.android.trisolarisserver.repo.property.PropertyRepo
|
import com.android.trisolarisserver.repo.PropertyRepo
|
||||||
import com.android.trisolarisserver.models.booking.GuestRating
|
import com.android.trisolarisserver.models.booking.GuestRating
|
||||||
import com.android.trisolarisserver.models.booking.GuestRatingScore
|
import com.android.trisolarisserver.models.booking.GuestRatingScore
|
||||||
import com.android.trisolarisserver.security.MyPrincipal
|
import com.android.trisolarisserver.security.MyPrincipal
|
||||||
@@ -45,9 +42,18 @@ class GuestRatings(
|
|||||||
@AuthenticationPrincipal principal: MyPrincipal?,
|
@AuthenticationPrincipal principal: MyPrincipal?,
|
||||||
@RequestBody request: GuestRatingCreateRequest
|
@RequestBody request: GuestRatingCreateRequest
|
||||||
): GuestRatingResponse {
|
): 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 {
|
val booking = bookingRepo.findById(request.bookingId).orElseThrow {
|
||||||
ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found")
|
ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found")
|
||||||
@@ -64,12 +70,13 @@ class GuestRatings(
|
|||||||
|
|
||||||
val score = parseScore(request.score)
|
val score = parseScore(request.score)
|
||||||
val rating = GuestRating(
|
val rating = GuestRating(
|
||||||
|
org = property.org,
|
||||||
property = property,
|
property = property,
|
||||||
guest = guest,
|
guest = guest,
|
||||||
booking = booking,
|
booking = booking,
|
||||||
score = score,
|
score = score,
|
||||||
notes = request.notes?.trim(),
|
notes = request.notes?.trim(),
|
||||||
createdBy = appUserRepo.findById(resolved.userId).orElse(null)
|
createdBy = appUserRepo.findById(principal.userId).orElse(null)
|
||||||
)
|
)
|
||||||
guestRatingRepo.save(rating)
|
guestRatingRepo.save(rating)
|
||||||
return rating.toResponse()
|
return rating.toResponse()
|
||||||
@@ -81,9 +88,18 @@ class GuestRatings(
|
|||||||
@PathVariable guestId: UUID,
|
@PathVariable guestId: UUID,
|
||||||
@AuthenticationPrincipal principal: MyPrincipal?
|
@AuthenticationPrincipal principal: MyPrincipal?
|
||||||
): List<GuestRatingResponse> {
|
): 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() }
|
return guestRatingRepo.findByGuestIdOrderByCreatedAtDesc(guestId).map { it.toResponse() }
|
||||||
}
|
}
|
||||||
@@ -101,6 +117,7 @@ class GuestRatings(
|
|||||||
private fun GuestRating.toResponse(): GuestRatingResponse {
|
private fun GuestRating.toResponse(): GuestRatingResponse {
|
||||||
return GuestRatingResponse(
|
return GuestRatingResponse(
|
||||||
id = id!!,
|
id = id!!,
|
||||||
|
orgId = org.id!!,
|
||||||
propertyId = property.id!!,
|
propertyId = property.id!!,
|
||||||
guestId = guest.id!!,
|
guestId = guest.id!!,
|
||||||
bookingId = booking.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.GetMapping
|
||||||
import org.springframework.web.bind.annotation.RestController
|
import org.springframework.web.bind.annotation.RestController
|
||||||
@@ -7,11 +7,11 @@ import org.springframework.web.bind.annotation.RestController
|
|||||||
class Health {
|
class Health {
|
||||||
@GetMapping("/health")
|
@GetMapping("/health")
|
||||||
fun health(): Map<String, String> {
|
fun health(): Map<String, String> {
|
||||||
return mapOf("status" to "ok", "build" to "2026-01-26-authfix")
|
return mapOf("status" to "ok")
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/")
|
@GetMapping("/")
|
||||||
fun root(): Map<String, String> {
|
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
|
package com.android.trisolarisserver.controller
|
||||||
import com.android.trisolarisserver.controller.common.requireMember
|
|
||||||
import com.android.trisolarisserver.controller.common.requirePrincipal
|
|
||||||
|
|
||||||
import com.android.trisolarisserver.component.storage.EmailStorage
|
import com.android.trisolarisserver.component.EmailStorage
|
||||||
import com.android.trisolarisserver.component.auth.PropertyAccess
|
import com.android.trisolarisserver.component.PropertyAccess
|
||||||
import com.android.trisolarisserver.repo.email.InboundEmailRepo
|
import com.android.trisolarisserver.db.repo.InboundEmailRepo
|
||||||
import com.android.trisolarisserver.models.booking.InboundEmail
|
import com.android.trisolarisserver.models.booking.InboundEmail
|
||||||
import com.android.trisolarisserver.models.booking.InboundEmailStatus
|
import com.android.trisolarisserver.models.booking.InboundEmailStatus
|
||||||
import com.android.trisolarisserver.models.property.Role
|
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.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.pdmodel.PDDocument
|
||||||
import org.apache.pdfbox.text.PDFTextStripper
|
import org.apache.pdfbox.text.PDFTextStripper
|
||||||
import org.springframework.http.HttpStatus
|
import org.springframework.http.HttpStatus
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
package com.android.trisolarisserver.controller.email
|
package com.android.trisolarisserver.controller
|
||||||
import com.android.trisolarisserver.controller.common.requireMember
|
|
||||||
import com.android.trisolarisserver.controller.common.requirePrincipal
|
|
||||||
|
|
||||||
import com.android.trisolarisserver.component.auth.PropertyAccess
|
import com.android.trisolarisserver.component.PropertyAccess
|
||||||
import com.android.trisolarisserver.repo.email.InboundEmailRepo
|
import com.android.trisolarisserver.db.repo.InboundEmailRepo
|
||||||
import com.android.trisolarisserver.models.property.Role
|
import com.android.trisolarisserver.models.property.Role
|
||||||
import com.android.trisolarisserver.security.MyPrincipal
|
import com.android.trisolarisserver.security.MyPrincipal
|
||||||
import org.springframework.core.io.FileSystemResource
|
import org.springframework.core.io.FileSystemResource
|
||||||
@@ -1,23 +1,17 @@
|
|||||||
package com.android.trisolarisserver.controller.card
|
package com.android.trisolarisserver.controller
|
||||||
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
|
|
||||||
|
|
||||||
import com.android.trisolarisserver.component.auth.PropertyAccess
|
import com.android.trisolarisserver.component.PropertyAccess
|
||||||
import com.android.trisolarisserver.controller.dto.booking.CardPrepareRequest
|
import com.android.trisolarisserver.controller.dto.CardPrepareRequest
|
||||||
import com.android.trisolarisserver.controller.dto.booking.CardPrepareResponse
|
import com.android.trisolarisserver.controller.dto.CardPrepareResponse
|
||||||
import com.android.trisolarisserver.controller.dto.booking.CardRevokeResponse
|
import com.android.trisolarisserver.controller.dto.IssueCardRequest
|
||||||
import com.android.trisolarisserver.controller.dto.booking.IssueCardRequest
|
import com.android.trisolarisserver.controller.dto.IssuedCardResponse
|
||||||
import com.android.trisolarisserver.controller.dto.booking.IssuedCardResponse
|
|
||||||
import com.android.trisolarisserver.models.property.Role
|
import com.android.trisolarisserver.models.property.Role
|
||||||
import com.android.trisolarisserver.models.room.IssuedCard
|
import com.android.trisolarisserver.models.room.IssuedCard
|
||||||
import com.android.trisolarisserver.repo.property.AppUserRepo
|
import com.android.trisolarisserver.repo.AppUserRepo
|
||||||
import com.android.trisolarisserver.repo.card.IssuedCardRepo
|
import com.android.trisolarisserver.repo.IssuedCardRepo
|
||||||
import com.android.trisolarisserver.repo.card.PropertyCardCounterRepo
|
import com.android.trisolarisserver.repo.PropertyCardCounterRepo
|
||||||
import com.android.trisolarisserver.repo.property.PropertyRepo
|
import com.android.trisolarisserver.repo.PropertyRepo
|
||||||
import com.android.trisolarisserver.repo.room.RoomStayRepo
|
import com.android.trisolarisserver.repo.RoomStayRepo
|
||||||
import com.android.trisolarisserver.security.MyPrincipal
|
import com.android.trisolarisserver.security.MyPrincipal
|
||||||
import org.springframework.http.HttpStatus
|
import org.springframework.http.HttpStatus
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||||
@@ -53,10 +47,18 @@ class IssuedCards(
|
|||||||
@AuthenticationPrincipal principal: MyPrincipal?,
|
@AuthenticationPrincipal principal: MyPrincipal?,
|
||||||
@RequestBody request: CardPrepareRequest
|
@RequestBody request: CardPrepareRequest
|
||||||
): CardPrepareResponse {
|
): CardPrepareResponse {
|
||||||
val actor = requireIssueActor(propertyId, principal)
|
requireIssueActor(propertyId, principal)
|
||||||
val stay = requireOpenRoomStayForProperty(roomStayRepo, propertyId, roomStayId, "Room stay closed")
|
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
|
val expiresAt = request.expiresAt?.let { parseOffset(it) } ?: stay.toAt
|
||||||
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "expiresAt required")
|
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "expiresAt required")
|
||||||
if (!expiresAt.isAfter(issuedAt)) {
|
if (!expiresAt.isAfter(issuedAt)) {
|
||||||
@@ -70,10 +72,7 @@ class IssuedCards(
|
|||||||
key = payload.key,
|
key = payload.key,
|
||||||
timeData = payload.timeData,
|
timeData = payload.timeData,
|
||||||
issuedAt = issuedAt.toString(),
|
issuedAt = issuedAt.toString(),
|
||||||
expiresAt = expiresAt.toString(),
|
expiresAt = expiresAt.toString()
|
||||||
sector3Block0 = encodeBlock(actor.name),
|
|
||||||
sector3Block1 = encodeBlock(actor.id?.toString()),
|
|
||||||
sector3Block2 = encodeBlock(null)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,8 +92,16 @@ class IssuedCards(
|
|||||||
if (request.cardIndex <= 0) {
|
if (request.cardIndex <= 0) {
|
||||||
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "cardIndex required")
|
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "cardIndex required")
|
||||||
}
|
}
|
||||||
val stay = requireOpenRoomStayForProperty(roomStayRepo, propertyId, roomStayId, "Room stay closed")
|
val stay = roomStayRepo.findById(roomStayId).orElseThrow {
|
||||||
val issuedAt = parseOffset(request.issuedAt) ?: nowForProperty(stay.property.timezone)
|
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)
|
val expiresAt = parseOffset(request.expiresAt)
|
||||||
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "expiresAt required")
|
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "expiresAt required")
|
||||||
if (!expiresAt.isAfter(issuedAt)) {
|
if (!expiresAt.isAfter(issuedAt)) {
|
||||||
@@ -127,43 +134,40 @@ class IssuedCards(
|
|||||||
@PathVariable roomStayId: UUID,
|
@PathVariable roomStayId: UUID,
|
||||||
@AuthenticationPrincipal principal: MyPrincipal?
|
@AuthenticationPrincipal principal: MyPrincipal?
|
||||||
): List<IssuedCardResponse> {
|
): List<IssuedCardResponse> {
|
||||||
requireViewActor(propertyId, principal)
|
requireMember(propertyId, principal)
|
||||||
val stay = requireRoomStayForProperty(roomStayRepo, propertyId, roomStayId)
|
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)
|
return issuedCardRepo.findByRoomStayIdOrderByIssuedAtDesc(roomStayId)
|
||||||
.map { it.toResponse() }
|
.map { it.toResponse() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/cards/{cardIndex}/revoke")
|
@PostMapping("/cards/{cardId}/revoke")
|
||||||
@org.springframework.transaction.annotation.Transactional
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
fun revoke(
|
fun revoke(
|
||||||
@PathVariable propertyId: UUID,
|
@PathVariable propertyId: UUID,
|
||||||
@PathVariable cardIndex: Int,
|
@PathVariable cardId: UUID,
|
||||||
@AuthenticationPrincipal principal: MyPrincipal?
|
@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")
|
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Card not found")
|
||||||
requireRevokeActor(propertyId, principal, card.roomStay == null)
|
|
||||||
if (card.revokedAt == null) {
|
if (card.revokedAt == null) {
|
||||||
val now = nowForProperty(card.property.timezone)
|
card.revokedAt = OffsetDateTime.now()
|
||||||
card.revokedAt = now
|
|
||||||
card.expiresAt = now
|
|
||||||
issuedCardRepo.save(card)
|
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}")
|
private fun parseOffset(value: String?): OffsetDateTime? {
|
||||||
fun getCardByIndex(
|
if (value.isNullOrBlank()) return null
|
||||||
@PathVariable propertyId: UUID,
|
return try {
|
||||||
@PathVariable cardIndex: Int,
|
OffsetDateTime.parse(value.trim())
|
||||||
@AuthenticationPrincipal principal: MyPrincipal?
|
} catch (_: Exception) {
|
||||||
): IssuedCardResponse {
|
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid timestamp")
|
||||||
requireCardAdminActor(propertyId, principal)
|
}
|
||||||
val card = issuedCardRepo.findByPropertyIdAndCardIndex(propertyId, cardIndex)
|
|
||||||
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Card not found")
|
|
||||||
return card.toResponse()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun requireMember(propertyId: UUID, principal: MyPrincipal?) {
|
private fun requireMember(propertyId: UUID, principal: MyPrincipal?) {
|
||||||
@@ -173,20 +177,6 @@ class IssuedCards(
|
|||||||
propertyAccess.requireMember(propertyId, principal.userId)
|
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 {
|
private fun requireIssueActor(propertyId: UUID, principal: MyPrincipal?): com.android.trisolarisserver.models.property.AppUser {
|
||||||
if (principal == null) {
|
if (principal == null) {
|
||||||
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
|
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) {
|
if (principal == null) {
|
||||||
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
|
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
|
||||||
}
|
}
|
||||||
propertyAccess.requireMember(propertyId, principal.userId)
|
propertyAccess.requireMember(propertyId, principal.userId)
|
||||||
if (isTempCard) {
|
|
||||||
propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN, Role.MANAGER)
|
|
||||||
} else {
|
|
||||||
propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN)
|
propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private fun requireCardAdminActor(propertyId: UUID, principal: MyPrincipal?) {
|
|
||||||
if (principal == null) {
|
|
||||||
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
|
|
||||||
}
|
|
||||||
propertyAccess.requireMember(propertyId, principal.userId)
|
|
||||||
propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN, Role.MANAGER)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private fun nextCardIndex(propertyId: UUID): Int {
|
private fun nextCardIndex(propertyId: UUID): Int {
|
||||||
var counter = counterRepo.findByPropertyIdForUpdate(propertyId)
|
var counter = counterRepo.findByPropertyIdForUpdate(propertyId)
|
||||||
@@ -227,7 +204,7 @@ class IssuedCards(
|
|||||||
}
|
}
|
||||||
counterRepo.save(com.android.trisolarisserver.models.room.PropertyCardCounter(
|
counterRepo.save(com.android.trisolarisserver.models.room.PropertyCardCounter(
|
||||||
property = property,
|
property = property,
|
||||||
nextIndex = 10001
|
nextIndex = 1
|
||||||
))
|
))
|
||||||
counter = counterRepo.findByPropertyIdForUpdate(propertyId)
|
counter = counterRepo.findByPropertyIdForUpdate(propertyId)
|
||||||
}
|
}
|
||||||
@@ -245,18 +222,59 @@ class IssuedCards(
|
|||||||
expiresAt: OffsetDateTime
|
expiresAt: OffsetDateTime
|
||||||
): Sector0Payload {
|
): Sector0Payload {
|
||||||
val key = buildSector0Block2(roomNumber, cardIndex)
|
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)
|
return Sector0Payload(key, finalData)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildSector0TimeData(
|
private fun buildSector0Block2(roomNumber: Int, cardID: Int): String {
|
||||||
issuedAt: OffsetDateTime,
|
val guestID = cardID + 1
|
||||||
expiresAt: OffsetDateTime,
|
val key = "${cardID}2F${guestID}"
|
||||||
key: String? = null
|
val finalRoom = if (roomNumber < 10) "0$roomNumber" else roomNumber.toString()
|
||||||
): String {
|
return "472F${key}00010000${finalRoom}0000"
|
||||||
val newData = "0000000000" + formatDateComponents(issuedAt) + formatDateComponents(expiresAt)
|
}
|
||||||
val checkSum = calculateChecksum((key ?: "") + newData)
|
|
||||||
return newData + checkSum
|
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 key: String,
|
||||||
val timeData: 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
|
package com.android.trisolarisserver.controller
|
||||||
import com.android.trisolarisserver.controller.common.requireMember
|
|
||||||
import com.android.trisolarisserver.controller.common.requirePrincipal
|
|
||||||
|
|
||||||
import com.android.trisolarisserver.component.auth.PropertyAccess
|
import com.android.trisolarisserver.component.PropertyAccess
|
||||||
import com.android.trisolarisserver.controller.dto.property.TransportModeStatusResponse
|
import com.android.trisolarisserver.controller.dto.TransportModeStatusResponse
|
||||||
import com.android.trisolarisserver.models.booking.TransportMode
|
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 com.android.trisolarisserver.security.MyPrincipal
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
@@ -33,10 +31,10 @@ class TransportModes(
|
|||||||
val property = propertyRepo.findById(propertyId).orElseThrow {
|
val property = propertyRepo.findById(propertyId).orElseThrow {
|
||||||
ResponseStatusException(org.springframework.http.HttpStatus.NOT_FOUND, "Property not found")
|
ResponseStatusException(org.springframework.http.HttpStatus.NOT_FOUND, "Property not found")
|
||||||
}
|
}
|
||||||
val allowed = if (property.allowedTransportModes.isNotEmpty()) {
|
val allowed = when {
|
||||||
property.allowedTransportModes
|
property.allowedTransportModes.isNotEmpty() -> property.allowedTransportModes
|
||||||
} else {
|
property.org.allowedTransportModes.isNotEmpty() -> property.org.allowedTransportModes
|
||||||
TransportMode.entries.toSet()
|
else -> TransportMode.entries.toSet()
|
||||||
}
|
}
|
||||||
return TransportMode.entries.map { mode ->
|
return TransportMode.entries.map { mode ->
|
||||||
TransportModeStatusResponse(
|
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
|
import java.util.UUID
|
||||||
|
|
||||||
@@ -10,6 +10,7 @@ data class GuestRatingCreateRequest(
|
|||||||
|
|
||||||
data class GuestRatingResponse(
|
data class GuestRatingResponse(
|
||||||
val id: UUID,
|
val id: UUID,
|
||||||
|
val orgId: UUID,
|
||||||
val propertyId: UUID,
|
val propertyId: UUID,
|
||||||
val guestId: UUID,
|
val guestId: UUID,
|
||||||
val bookingId: 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
|
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(
|
data class PropertyCreateRequest(
|
||||||
|
val code: String,
|
||||||
val name: String,
|
val name: String,
|
||||||
val addressText: String? = null,
|
val addressText: String? = null,
|
||||||
val timezone: String? = null,
|
val timezone: String? = null,
|
||||||
val currency: String? = null,
|
val currency: String? = null,
|
||||||
val billingCheckinTime: String? = null,
|
|
||||||
val billingCheckoutTime: String? = null,
|
|
||||||
val active: Boolean? = null,
|
val active: Boolean? = null,
|
||||||
val otaAliases: Set<String>? = null,
|
val otaAliases: Set<String>? = null,
|
||||||
val emailAddresses: Set<String>? = null,
|
val emailAddresses: Set<String>? = null,
|
||||||
@@ -21,8 +33,6 @@ data class PropertyUpdateRequest(
|
|||||||
val addressText: String? = null,
|
val addressText: String? = null,
|
||||||
val timezone: String? = null,
|
val timezone: String? = null,
|
||||||
val currency: String? = null,
|
val currency: String? = null,
|
||||||
val billingCheckinTime: String? = null,
|
|
||||||
val billingCheckoutTime: String? = null,
|
|
||||||
val active: Boolean? = null,
|
val active: Boolean? = null,
|
||||||
val otaAliases: Set<String>? = null,
|
val otaAliases: Set<String>? = null,
|
||||||
val emailAddresses: Set<String>? = null,
|
val emailAddresses: Set<String>? = null,
|
||||||
@@ -31,61 +41,31 @@ data class PropertyUpdateRequest(
|
|||||||
|
|
||||||
data class PropertyResponse(
|
data class PropertyResponse(
|
||||||
val id: UUID,
|
val id: UUID,
|
||||||
|
val orgId: UUID,
|
||||||
val code: String,
|
val code: String,
|
||||||
val name: String,
|
val name: String,
|
||||||
val addressText: String?,
|
val addressText: String?,
|
||||||
val timezone: String,
|
val timezone: String,
|
||||||
val currency: String,
|
val currency: String,
|
||||||
val billingCheckinTime: String,
|
|
||||||
val billingCheckoutTime: String,
|
|
||||||
val active: Boolean,
|
val active: Boolean,
|
||||||
val otaAliases: Set<String>,
|
val otaAliases: Set<String>,
|
||||||
val emailAddresses: Set<String>,
|
val emailAddresses: Set<String>,
|
||||||
val allowedTransportModes: 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(
|
data class GuestResponse(
|
||||||
val id: UUID,
|
val id: UUID,
|
||||||
|
val orgId: UUID,
|
||||||
val name: String?,
|
val name: String?,
|
||||||
val phoneE164: String?,
|
val phoneE164: String?,
|
||||||
val dob: String?,
|
|
||||||
val nationality: String?,
|
val nationality: String?,
|
||||||
val addressText: String?,
|
val addressText: String?,
|
||||||
val signatureUrl: String?,
|
|
||||||
val vehicleNumbers: Set<String>,
|
val vehicleNumbers: Set<String>,
|
||||||
val averageScore: Double?
|
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(
|
data class GuestVehicleRequest(
|
||||||
val vehicleNumber: String,
|
val vehicleNumber: String
|
||||||
val bookingId: UUID
|
|
||||||
)
|
|
||||||
|
|
||||||
data class GuestVisitCountResponse(
|
|
||||||
val guestId: UUID?,
|
|
||||||
val bookingCount: Long
|
|
||||||
)
|
)
|
||||||
|
|
||||||
data class TransportModeStatusResponse(
|
data class TransportModeStatusResponse(
|
||||||
@@ -95,21 +75,17 @@ data class TransportModeStatusResponse(
|
|||||||
|
|
||||||
data class UserResponse(
|
data class UserResponse(
|
||||||
val id: UUID,
|
val id: UUID,
|
||||||
|
val orgId: UUID,
|
||||||
val firebaseUid: String?,
|
val firebaseUid: String?,
|
||||||
val phoneE164: String?,
|
val phoneE164: String?,
|
||||||
val name: String?,
|
val name: String?,
|
||||||
val disabled: Boolean,
|
val disabled: Boolean
|
||||||
val superAdmin: Boolean
|
|
||||||
)
|
)
|
||||||
|
|
||||||
data class PropertyUserRoleRequest(
|
data class PropertyUserRoleRequest(
|
||||||
val roles: Set<String>
|
val roles: Set<String>
|
||||||
)
|
)
|
||||||
|
|
||||||
data class PropertyUserDisableRequest(
|
|
||||||
val disabled: Boolean
|
|
||||||
)
|
|
||||||
|
|
||||||
data class PropertyUserResponse(
|
data class PropertyUserResponse(
|
||||||
val userId: UUID,
|
val userId: UUID,
|
||||||
val propertyId: 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
|
import java.util.UUID
|
||||||
|
|
||||||
@@ -6,13 +6,12 @@ data class RoomResponse(
|
|||||||
val id: UUID,
|
val id: UUID,
|
||||||
val roomNumber: Int,
|
val roomNumber: Int,
|
||||||
val floor: Int?,
|
val floor: Int?,
|
||||||
|
val roomTypeId: UUID,
|
||||||
val roomTypeName: String,
|
val roomTypeName: String,
|
||||||
val hasNfc: Boolean,
|
val hasNfc: Boolean,
|
||||||
val active: Boolean,
|
val active: Boolean,
|
||||||
val maintenance: Boolean,
|
val maintenance: Boolean,
|
||||||
val notes: String?,
|
val notes: String?
|
||||||
val tempCardActive: Boolean = false,
|
|
||||||
val tempCardExpiresAt: String? = null
|
|
||||||
)
|
)
|
||||||
|
|
||||||
data class RoomBoardResponse(
|
data class RoomBoardResponse(
|
||||||
@@ -27,27 +26,19 @@ data class RoomAvailabilityResponse(
|
|||||||
)
|
)
|
||||||
|
|
||||||
data class RoomAvailabilityRangeResponse(
|
data class RoomAvailabilityRangeResponse(
|
||||||
val roomTypeCode: String,
|
|
||||||
val roomTypeName: String,
|
val roomTypeName: String,
|
||||||
val freeRoomNumbers: List<Int>,
|
val freeRoomNumbers: List<Int>,
|
||||||
val freeCount: Int,
|
val freeCount: Int
|
||||||
val averageRate: Double?,
|
|
||||||
val currency: String,
|
|
||||||
val ratePlanCode: String? = null
|
|
||||||
)
|
)
|
||||||
|
|
||||||
data class RoomImageResponse(
|
data class RoomImageResponse(
|
||||||
val id: UUID,
|
val id: UUID,
|
||||||
val propertyId: UUID,
|
val propertyId: UUID,
|
||||||
val roomId: UUID,
|
val roomId: UUID,
|
||||||
val roomTypeCode: String?,
|
|
||||||
val url: String,
|
val url: String,
|
||||||
val thumbnailUrl: String,
|
val thumbnailUrl: String,
|
||||||
val contentType: String,
|
val contentType: String,
|
||||||
val sizeBytes: Long,
|
val sizeBytes: Long,
|
||||||
val tags: Set<RoomImageTagResponse>,
|
|
||||||
val roomSortOrder: Int,
|
|
||||||
val roomTypeSortOrder: Int,
|
|
||||||
val createdAt: String
|
val createdAt: String
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -61,26 +52,9 @@ enum class RoomBoardStatus {
|
|||||||
data class RoomUpsertRequest(
|
data class RoomUpsertRequest(
|
||||||
val roomNumber: Int,
|
val roomNumber: Int,
|
||||||
val floor: Int?,
|
val floor: Int?,
|
||||||
val roomTypeCode: String,
|
val roomTypeId: UUID,
|
||||||
val hasNfc: Boolean,
|
val hasNfc: Boolean,
|
||||||
val active: Boolean,
|
val active: Boolean,
|
||||||
val maintenance: Boolean,
|
val maintenance: Boolean,
|
||||||
val notes: String?
|
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