Compare commits

...

79 Commits

Author SHA1 Message Date
androidlover5842
46f9fecf4a Remove amenity schema auto-fix
All checks were successful
build-and-deploy / build-deploy (push) Successful in 27s
2026-01-27 04:49:57 +05:30
androidlover5842
fb1c0caed7 Prevent deleting amenities used by room types
All checks were successful
build-and-deploy / build-deploy (push) Successful in 27s
2026-01-27 04:47:24 +05:30
androidlover5842
4fdfc84811 Auto-fix room_amenity schema for global amenities
All checks were successful
build-and-deploy / build-deploy (push) Successful in 27s
2026-01-27 04:44:29 +05:30
androidlover5842
19153900fd Remove amenity description field
All checks were successful
build-and-deploy / build-deploy (push) Successful in 27s
2026-01-27 04:37:19 +05:30
androidlover5842
eb0b99f55a Make amenities global and super-admin managed
All checks were successful
build-and-deploy / build-deploy (push) Successful in 27s
2026-01-27 04:33:51 +05:30
androidlover5842
c3ec6e8d4a Add amenity category and icon fields
All checks were successful
build-and-deploy / build-deploy (push) Successful in 27s
2026-01-27 04:21:20 +05:30
androidlover5842
3a8f871d7d Eager-load amenities when fetching room type
All checks were successful
build-and-deploy / build-deploy (push) Successful in 27s
2026-01-27 04:06:32 +05:30
androidlover5842
a0a9ce4d31 Add amenities and size fields to room types
All checks were successful
build-and-deploy / build-deploy (push) Successful in 27s
2026-01-27 04:04:30 +05:30
androidlover5842
f9c31a4d59 Avoid lazy roomType access in room create/update responses
All checks were successful
build-and-deploy / build-deploy (push) Successful in 32s
2026-01-27 03:21:38 +05:30
androidlover5842
236c885954 Remove roomTypeId from room responses
All checks were successful
build-and-deploy / build-deploy (push) Successful in 27s
2026-01-27 03:17:35 +05:30
androidlover5842
a39a9dcd1f Require roomTypeCode for room upsert
All checks were successful
build-and-deploy / build-deploy (push) Successful in 27s
2026-01-27 03:14:58 +05:30
androidlover5842
40a09d1c83 Eagerly fetch room type aliases with rooms
All checks were successful
build-and-deploy / build-deploy (push) Successful in 27s
2026-01-27 03:05:59 +05:30
androidlover5842
0104e87050 Eagerly fetch room type when loading room by id
All checks were successful
build-and-deploy / build-deploy (push) Successful in 27s
2026-01-27 03:04:09 +05:30
androidlover5842
1082126f86 Eagerly fetch room type aliases for list
All checks were successful
build-and-deploy / build-deploy (push) Successful in 28s
2026-01-27 02:54:48 +05:30
androidlover5842
188738e28b Return JSON error bodies for auth and exceptions
All checks were successful
build-and-deploy / build-deploy (push) Successful in 27s
2026-01-27 02:52:08 +05:30
androidlover5842
7f7e164acf Remove debug headers and return 403 on access denied
All checks were successful
build-and-deploy / build-deploy (push) Successful in 26s
2026-01-27 02:42:07 +05:30
androidlover5842
c2c54d24f5 Add detailed room create debug steps and exception header
All checks were successful
build-and-deploy / build-deploy (push) Successful in 27s
2026-01-27 02:33:09 +05:30
androidlover5842
9aa1f71c32 Add debug exception resolver for troubleshooting
All checks were successful
build-and-deploy / build-deploy (push) Successful in 27s
2026-01-27 02:30:14 +05:30
androidlover5842
ad1ef6ec0a Add room create progress debug headers
All checks were successful
build-and-deploy / build-deploy (push) Successful in 26s
2026-01-27 02:25:30 +05:30
androidlover5842
8f6e645573 Add member check debug header on room create
All checks were successful
build-and-deploy / build-deploy (push) Successful in 27s
2026-01-27 02:23:11 +05:30
androidlover5842
d38a29111d Fix Room controller debug response import
All checks were successful
build-and-deploy / build-deploy (push) Successful in 26s
2026-01-27 02:21:20 +05:30
androidlover5842
4f2eb3d671 Add principal debug header on room create
Some checks failed
build-and-deploy / build-deploy (push) Failing after 21s
2026-01-27 02:19:12 +05:30
androidlover5842
8c4afb8232 Expose downstream exception header for auth debug
All checks were successful
build-and-deploy / build-deploy (push) Successful in 27s
2026-01-27 02:16:56 +05:30
androidlover5842
be814eb0d5 Add access denied debug header for auth troubleshooting
All checks were successful
build-and-deploy / build-deploy (push) Successful in 27s
2026-01-27 02:14:38 +05:30
androidlover5842
3f05484498 Add optional auth debug response header
All checks were successful
build-and-deploy / build-deploy (push) Successful in 27s
2026-01-27 02:11:24 +05:30
androidlover5842
d32c89d768 Improve property access denial reasons
All checks were successful
build-and-deploy / build-deploy (push) Successful in 27s
2026-01-27 01:26:06 +05:30
androidlover5842
ce75e9536c Allow room upsert by roomTypeCode
All checks were successful
build-and-deploy / build-deploy (push) Successful in 27s
2026-01-27 01:13:59 +05:30
androidlover5842
0efce2f900 Add active room stays endpoint
All checks were successful
build-and-deploy / build-deploy (push) Successful in 27s
2026-01-27 00:37:27 +05:30
androidlover5842
22a9fdc851 Allow super admin to manage property roles
All checks were successful
build-and-deploy / build-deploy (push) Successful in 26s
2026-01-26 23:53:21 +05:30
androidlover5842
1400451bfe Remove org model; make AppUser global with super admin
All checks were successful
build-and-deploy / build-deploy (push) Successful in 27s
2026-01-26 22:33:59 +05:30
androidlover5842
bf87d329d4 Allow updating AppUser name via auth/me
Some checks failed
build-and-deploy / build-deploy (push) Failing after 22s
2026-01-26 22:30:48 +05:30
androidlover5842
63c7479c9f Return 202 when auth needs org
All checks were successful
build-and-deploy / build-deploy (push) Successful in 29s
2026-01-26 22:00:09 +05:30
androidlover5842
721580ffd7 Persist pending users when no org exists
All checks were successful
build-and-deploy / build-deploy (push) Successful in 27s
2026-01-26 21:57:52 +05:30
androidlover5842
650e7c7354 Return NEEDS_ORG when no org exists
All checks were successful
build-and-deploy / build-deploy (push) Successful in 27s
2026-01-26 21:54:53 +05:30
androidlover5842
619a48dd4f Auto-create AppUser on first verify
All checks were successful
build-and-deploy / build-deploy (push) Successful in 28s
2026-01-26 21:49:45 +05:30
androidlover5842
e3a7053d78 Log auth verify user-not-found
All checks were successful
build-and-deploy / build-deploy (push) Successful in 27s
2026-01-26 21:45:26 +05:30
androidlover5842
85254b229f Log auth verify failures in prod
All checks were successful
build-and-deploy / build-deploy (push) Successful in 27s
2026-01-26 21:42:16 +05:30
androidlover5842
2c337b8709 Return 401 for auth failures and log verify
All checks were successful
build-and-deploy / build-deploy (push) Successful in 27s
2026-01-26 21:36:22 +05:30
androidlover5842
7ec8d6d350 Log auth verify entry
All checks were successful
build-and-deploy / build-deploy (push) Successful in 26s
2026-01-26 21:33:13 +05:30
androidlover5842
8f47725f13 Use single security chain with auth permitAll
All checks were successful
build-and-deploy / build-deploy (push) Successful in 27s
2026-01-26 21:28:22 +05:30
androidlover5842
398ad93232 Adjust health response and auth chain ordering
All checks were successful
build-and-deploy / build-deploy (push) Successful in 27s
2026-01-26 21:25:17 +05:30
androidlover5842
05b8fd409c Simplify security config and permit auth endpoints
All checks were successful
build-and-deploy / build-deploy (push) Successful in 27s
2026-01-26 21:20:20 +05:30
androidlover5842
6f961cb599 Fix auth verify access and token fallback
All checks were successful
build-and-deploy / build-deploy (push) Successful in 27s
2026-01-26 21:15:06 +05:30
androidlover5842
d895c4411d add debug to auth verify
All checks were successful
build-and-deploy / build-deploy (push) Successful in 27s
2026-01-26 21:09:38 +05:30
androidlover5842
397bc4ede3 enable security config
All checks were successful
build-and-deploy / build-deploy (push) Successful in 27s
2026-01-26 21:04:24 +05:30
androidlover5842
e1680b1991 test validation erro
All checks were successful
build-and-deploy / build-deploy (push) Successful in 27s
2026-01-26 20:58:57 +05:30
androidlover5842
14f739a54f change java version
All checks were successful
build-and-deploy / build-deploy (push) Successful in 1m2s
2026-01-26 19:25:23 +05:30
deploy
3a5726203c Deploy without sudo cp
All checks were successful
build-and-deploy / build-deploy (push) Successful in 4m57s
2026-01-26 18:49:44 +05:30
androidlover5842
32af3e0d82 -_-
Some checks failed
build-and-deploy / build-deploy (push) Has been cancelled
2026-01-26 18:31:05 +05:30
androidlover5842
4ec4e5e068 Merge branch 'master' of git.hoteltrisolaris.in:androidlover5842/TrisolarisServer 2026-01-26 18:30:49 +05:30
androidlover5842
6963a0f252 -_- 2026-01-26 18:29:24 +05:30
deploy
812bb4ffb4 Add Gradle info logs
Some checks failed
build-and-deploy / build-deploy (push) Has been cancelled
2026-01-26 18:12:47 +05:30
deploy
152bbbc575 Use Gradle daemon in CI
Some checks failed
build-and-deploy / build-deploy (push) Has been cancelled
2026-01-26 18:11:08 +05:30
androidlover5842
671922b363 testing deployment..
Some checks failed
build-and-deploy / build-deploy (push) Has been cancelled
2026-01-26 18:00:25 +05:30
deploy
41299e6071 Move Gradle workers cap to CI
Some checks failed
build-and-deploy / build-deploy (push) Has been cancelled
2026-01-26 17:57:07 +05:30
deploy
8887e456ac Set Gradle workers to CPU count
Some checks failed
build-and-deploy / build-deploy (push) Has been cancelled
2026-01-26 17:54:38 +05:30
deploy
4f22bfa234 Increase Gradle JVM heap to 10g
Some checks failed
build-and-deploy / build-deploy (push) Has been cancelled
2026-01-26 17:53:22 +05:30
deploy
7b1bb55008 Enable Gradle cache and config cache
Some checks failed
build-and-deploy / build-deploy (push) Has been cancelled
2026-01-26 17:51:38 +05:30
deploy
9ff84c2111 Use JDK 19 and simplify CI
Some checks failed
build-and-deploy / build-deploy (push) Has been cancelled
2026-01-26 17:45:33 +05:30
androidlover5842
1f43f3274a better reployment
Some checks failed
build-and-deploy / build (push) Has been cancelled
2026-01-26 17:42:55 +05:30
androidlover5842
21fd32aeee improve deployment
Some checks failed
build-and-deploy / build (push) Failing after 3m50s
build-and-deploy / deploy (push) Has been skipped
2026-01-26 17:32:14 +05:30
androidlover5842
b972563971 upload artifact
Some checks failed
build / deploy (push) Has been cancelled
build / build (push) Has been cancelled
2026-01-26 17:26:08 +05:30
androidlover5842
af280ca88c give assemblu logs
All checks were successful
build / build (push) Successful in 3m23s
build / deploy (push) Successful in 0s
2026-01-26 17:13:44 +05:30
androidlover5842
e8d9590a37 revert host 2026-01-26 17:12:42 +05:30
androidlover5842
97072e314a change buid host
Some checks failed
build / build (push) Has been cancelled
build / deploy (push) Has been cancelled
2026-01-26 16:58:41 +05:30
androidlover5842
0afcc9b735 auto deploy
Some checks failed
build / build (push) Has been cancelled
build / deploy (push) Has been cancelled
2026-01-26 16:56:37 +05:30
androidlover5842
a206041b0c fix /helth api for testing 2026-01-26 16:50:36 +05:30
androidlover5842
8997cffc16 add test page 2026-01-26 16:24:16 +05:30
androidlover5842
b2441fe291 change server port 2026-01-26 16:08:21 +05:30
androidlover5842
de7e293097 build guest rating system 2026-01-26 15:09:20 +05:30
androidlover5842
31398d3822 fix compileation eror 2026-01-26 14:56:52 +05:30
androidlover5842
9835435aab update ai data 2026-01-24 23:57:01 +05:30
androidlover5842
094673b475 move card issuence server side 2026-01-24 23:53:03 +05:30
androidlover5842
ab7f02ddc6 add see update 2026-01-24 23:18:15 +05:30
androidlover5842
4a2834819f pre assign rooms before checkin 2026-01-24 23:07:45 +05:30
androidlover5842
57ca477c08 add booking flow 2026-01-24 22:58:54 +05:30
androidlover5842
72d9f5bb12 modes of transport 2026-01-24 22:39:45 +05:30
androidlover5842
6b6d84e40a more codes 2026-01-24 22:22:37 +05:30
androidlover5842
0d3472c60e filter mails by property contact alias 2026-01-24 21:57:06 +05:30
71 changed files with 3239 additions and 545 deletions

View File

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

341
AGENTS.md
View File

@@ -1,127 +1,36 @@
PROJECT CONTEXT / SYSTEM BRIEF
This is a hotel-grade Property Management System (PMS) being rebuilt from scratch.
This AGENTS file captures both the product rules you gave and my current understanding of
the TrisolarisServer codebase as of the last read, so future sessions can resume accurately.
This AGENTS file captures product rules + current codebase state.
Tech stack
- Spring Boot monolith
- Kotlin only
- JPA / Hibernate
- PostgreSQL
- No Flyway for now, schema via JPA during development (Flyway is present in deps but disabled)
- Flyway deps present but disabled (no migrations during dev)
- Single API domain api.hoteltrisolaris.in
- Android app and future website consume the same APIs
Server specs (current)
- CPU: i5-8400
- RAM: 48 GB
- GPU: RTX 3060 (used for llama.cpp)
This replaces a very old Firestore-based Android app. Old code exists only to understand behaviour. Do not reuse or mirror old models.
Core principles
- Server is source of truth; clients send intent.
- Ledger-based design: never store totals; append rows only.
- Occupancy = RoomStay. Billing = Charge. Payments = Payment. Invoices are derived.
- Room availability by room number; toAt=null means occupied.
- Room change = close old RoomStay + open new one.
- Multi-property: every domain object scoped to property_id.
- AppUser is global; access granted per property.
CORE DESIGN PRINCIPLES
Server is the only source of truth
Clients never calculate state
Clients send intent, server derives facts
Ledger-based design
Never store totals
Never overwrite money
Append rows and derive views
Three independent concerns
Occupancy handled by RoomStay
Charges handled by Charge
Payments handled by Payment
Invoices are derived views, not stored state
Room availability is by room number
Availability is derived from RoomStay
toAt = null means occupied
Category counts are only summaries
Room changes must never break billing
Changing rooms means closing one RoomStay and opening another
Charges are time-bound and linked to booking, optionally to room_stay
Multi-property from day one
Every domain object is scoped to property_id
Users belong to organization
Access is granted per property
Auth and access
User exists once as AppUser
Property access via PropertyUser
Roles are per property
Every API call must enforce property membership
CURRENT DOMAIN MODEL ALREADY CREATED
Organization
Property
AppUser
PropertyUser with roles
RoomType
Room
Booking
Guest
RoomStay
These entities already exist in Kotlin. Do not redesign unless explicitly asked.
WHAT THE SYSTEM MUST SUPPORT
Operational behaviour
- Staff sees exact room numbers free, occupied, checkout today
- Different rates for same room type
- Multiple rooms today and fewer tomorrow under the same booking
- Room changes without data loss
Financial behaviour
- Advance payments, partial payments, refunds
- PayU integration for QR and payment links
- Payment status via webhooks
- Clear source and destination of money
- Staff-wise collection tracking
Website integration
- Website reads live availability from PMS
- Website creates booking intents
- No inventory sync jobs
- Rate plans like DIRECT, WALKIN, OTA are snapshotted at booking time
Realtime
- Firestore-like realtime behaviour
- WebSocket or SSE for room board and payment updates
- Push notifications later via FCM
Infrastructure
- Nginx reverse proxy
- Single domain with multiple paths
- Database is never exposed publicly
IMPORTANT RULES FOR YOU
- Use Kotlin only
- Follow existing package structure
- No speculative features
- No premature microservices
- Flyway must remain disabled during development. Do not introduce or modify Flyway migrations unless explicitly instructed after schema stabilization.
- Propose schema or API changes before coding if unsure
- Money logic must be explicit and auditable
- Canonical staff roles for now are ADMIN, MANAGER, STAFF, HOUSEKEEPING, FINANCE. Other roles may exist later but must not be depended on unless asked.
- Booking is a lifecycle container only. Booking does not own rooms or money. Occupancy is only via RoomStay. Billing is only via Charge and Payment ledgers.
- Realtime features must emit derived domain events only. Clients must never subscribe to raw entity state or database changes.
HOW YOU SHOULD WORK
- Read the entire repository before making changes
- Work file by file
- Prefer small focused changes
- Ask before touching auth or payment logic
- Assume this will run in real production hotels
FIRST TASK
Do nothing until asked.
Likely upcoming tasks include room board API, charge ledger, payment and PayU webhook flow, booking check-in transaction.
Immutable rules
- Use Kotlin only; no microservices.
- Flyway must remain disabled until schema stabilizes.
- Canonical staff roles: ADMIN, MANAGER, STAFF, HOUSEKEEPING, FINANCE.
- Booking does not own rooms or money.
- Realtime events must be derived, not raw DB changes.
- Ask before touching auth or payment logic.
===============================================================================
CURRENT CODEBASE UNDERSTANDING (TrisolarisServer)
@@ -129,119 +38,115 @@ CURRENT CODEBASE UNDERSTANDING (TrisolarisServer)
Repository
- Root: /home/androidlover5842/IdeaProjects/TrisolarisServer
- Language: Kotlin only (Spring Boot 4, JPA)
- Entry point: src/main/kotlin/com/android/trisolarisserver/TrisolarisServerApplication.kt
- Active controller layer is minimal (Rooms.kt is stubbed/commented)
- Entry: src/main/kotlin/com/android/trisolarisserver/TrisolarisServerApplication.kt
- Scheduling enabled (@EnableScheduling)
Gradle
- build.gradle.kts uses:
- Spring Boot 4.0.1
- Kotlin 2.2.21 (kotlin("jvm"), kotlin("plugin.spring"), kotlin("plugin.jpa"))
- Java toolchain 19
- JPA, WebMVC, Validation, Security, WebSocket, Flyway (dep), Postgres
- Flyway is disabled in application.properties
Security/Auth
- Firebase Admin auth for every request; Firebase UID required.
- /auth/verify and /auth/me.
Configuration
- src/main/resources/application.properties
- spring.jpa.hibernate.ddl-auto=update
- spring.jpa.open-in-view=false
- flyway.enabled=false
- application-dev.properties -> jdbc:postgresql://192.168.1.53:5432/trisolaris
- application-prod.properties -> jdbc:postgresql://localhost:5432/trisolaris
- DB password via env: DB_PASSWORD
Domain entities
- Property: code, name, addressText, emailAddresses, otaAliases, allowedTransportModes.
- AppUser (global, superAdmin), PropertyUser (roles per property).
- RoomType: code/name/occupancy + otaAliases.
- Room: roomNumber, floor, hasNfc, active, maintenance, notes.
- Booking: status, expected check-in/out, emailAuditPdfUrl, transportMode, transportVehicleNumber.
- Guest (property-scoped).
- RoomStay.
- RoomStayChange (idempotent room move).
- IssuedCard (cardId, cardIndex, issuedAt, expiresAt, issuedBy, revokedAt).
- PropertyCardCounter (per-property cardIndex counter).
- GuestDocument (files + AI-extracted json).
- GuestVehicle (property-scoped vehicle numbers).
- InboundEmail (audit PDF + raw EML, extracted json, status).
- RoomImage (original + thumbnail).
Current packages and code
Key modules
com.android.trisolarisserver.component
- PropertyAccess
- requireMember(propertyId, userId) -> checks PropertyUserRepo
- requireAnyRole(propertyId, userId, roles) -> checks roles
Auth
- /auth/verify
- /auth/me
com.android.trisolarisserver.db.repo
- PropertyUserRepo
- existsByIdPropertyIdAndIdUserId(...)
- hasAnyRole(...) via JPQL joining property_user_role
- RoomRepo
- findFreeRooms(propertyId): active, not maintenance, no open RoomStay
- findOccupiedRooms(propertyId): rooms with active RoomStay
Properties / Users
- POST /properties (creator becomes ADMIN on that property)
- GET /properties (super admin gets all; others get memberships)
- PUT /properties/{propertyId}
- GET /properties/{propertyId}/users
- PUT /properties/{propertyId}/users/{userId}/roles
- DELETE /properties/{propertyId}/users/{userId} (ADMIN only)
com.android.trisolarisserver.controller
- Rooms.kt: placeholder, no active endpoints yet
Rooms / inventory
- /properties/{propertyId}/rooms
- /properties/{propertyId}/rooms/board
- /properties/{propertyId}/rooms/board/stream (SSE)
- /properties/{propertyId}/rooms/availability
- /properties/{propertyId}/rooms/availability-range?from=YYYY-MM-DD&to=YYYY-MM-DD
Entity model (current Kotlin entities)
- Organization
- id (uuid), name, createdAt
- Property
- id (uuid)
- org (Organization)
- code, name, timezone, currency
- active, createdAt
- AppUser
- id (uuid)
- org (Organization)
- firebaseUid, phoneE164, name
- disabled, createdAt
- PropertyUser
- composite key PropertyUserId (propertyId, userId)
- property (Property), user (AppUser)
- roles (ElementCollection of Role)
- Role enum
- ADMIN, MANAGER, STAFF, HOUSEKEEPING, FINANCE, GUIDE, SUPERVISOR, AGENT
- RoomType
- id (uuid)
- property (Property)
- code, name, baseOccupancy, maxOccupancy, createdAt
- Room
- id (uuid)
- property (Property)
- roomType (RoomType)
- roomNumber, floor
- hasNfc, active, maintenance, notes
- Booking
- id (uuid)
- property (Property)
- primaryGuest (Guest)
- status (BookingStatus)
- source, sourceBookingId
- checkinAt, checkoutAt
- expectedCheckinAt, expectedCheckoutAt
- notes
- createdBy (AppUser)
- createdAt, updatedAt
- BookingStatus enum
- OPEN, CHECKED_IN, CHECKED_OUT, CANCELLED, NO_SHOW
- Guest
- id (uuid)
- org (Organization)
- phoneE164, name, nationality
- addressText
- createdAt, updatedAt
- RoomStay
- id (uuid)
- property (Property)
- booking (Booking)
- room (Room)
- fromAt, toAt (null = active occupancy)
- createdBy (AppUser)
- createdAt
Room types
- POST /properties/{propertyId}/room-types
- GET /properties/{propertyId}/room-types
- PUT /properties/{propertyId}/room-types/{roomTypeId}
- DELETE /properties/{propertyId}/room-types/{roomTypeId}
Notes on schema vs migration file
- There is a Flyway migration file at src/main/resources/db/migration/V1__core.sql,
but Flyway is disabled. The SQL file does NOT match current Kotlin entities in
multiple places (columns and tables differ). For now, JPA schema generation is
authoritative during development.
Properties
- Property create/update accepts addressText, otaAliases, emailAddresses, allowedTransportModes.
Gaps relative to target design (do not implement unless asked)
- No Charge entity yet
- No Payment entity yet
- No ledger/derived views
- No API controllers/services for bookings, rooms, payments
- No auth filter or principal model wired (PropertyAccess expects userId)
- No WebSocket/SSE endpoints yet
Booking flow
- /properties/{propertyId}/bookings/{bookingId}/check-in (creates RoomStay rows)
- /properties/{propertyId}/bookings/{bookingId}/check-out (closes RoomStay)
- /properties/{propertyId}/bookings/{bookingId}/cancel
- /properties/{propertyId}/bookings/{bookingId}/no-show
- /properties/{propertyId}/bookings/{bookingId}/room-stays (pre-assign RoomStay with date range)
- /properties/{propertyId}/room-stays/{roomStayId}/change-room (idempotent via RoomStayChange)
Behavioral requirements to keep in mind when coding
- Every domain object must include property scope
- Room availability derived from RoomStay toAt == null
- Room changes are new RoomStay + closing old
- Charges and payments are append-only (never overwrite totals)
- Clients send intent; server derives facts
Card issuing
- /properties/{propertyId}/room-stays/{roomStayId}/cards/prepare -> returns cardIndex + sector0 payload
- /properties/{propertyId}/room-stays/{roomStayId}/cards -> store issued card
- /properties/{propertyId}/room-stays/{roomStayId}/cards (list)
- /properties/{propertyId}/room-stays/cards/{cardId}/revoke (ADMIN only)
Guest APIs
- /properties/{propertyId}/guests/search?phone=... or ?vehicleNumber=...
- /properties/{propertyId}/guests/{guestId}/vehicles (add vehicle)
Guest documents
- /properties/{propertyId}/guests/{guestId}/documents (upload/list)
- /properties/{propertyId}/guests/{guestId}/documents/{documentId}/file
- AI extraction with strict system prompt.
Room images
- /properties/{propertyId}/rooms/{roomId}/images (upload/list)
- /properties/{propertyId}/rooms/{roomId}/images/{imageId}/file
- Thumbnails generated (320px).
Transport modes
- /properties/{propertyId}/transport-modes -> returns enabled list (property or default all).
Inbound email ingestion
- IMAP poller (1 min) with enable flag.
- Saves audit PDF + raw .eml under /home/androidlover5842/docs/emails.
- Property match: To/CC email first; fallback to name/code/address/otaAliases.
- AI extracts booking fields; creates/cancels Booking.
- /properties/{propertyId}/inbound-emails/{emailId}/file (audit PDF)
- POST /properties/{propertyId}/inbound-emails/manual (PDF upload)
Realtime
- SSE room board events with heartbeat, on room create/update, check-in/out, and room change.
AI integration
- Base URL per profile: dev=https://ai.hoteltrisolaris.in/v1/chat/completions, prod=http://localhost:8089/v1/chat/completions
- LlamaClient uses strict system prompt (no guessing).
- Read timeout 5 minutes.
Config
- storage.documents.root=/home/androidlover5842/docs
- storage.emails.root=/home/androidlover5842/docs/emails
- storage.rooms.root=/home/androidlover5842/docs/rooms
- publicBaseUrl entries for docs/emails/rooms
- mail.imap.enabled=false by default
Notes / constraints
- Users are created by app; API only manages roles.
- Super admin can create properties and assign users to properties.
- Admin can assign ADMIN/MANAGER/STAFF/AGENT; Manager can assign STAFF/AGENT.
- Agents can only see free rooms.

View File

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

8
gradle.properties Normal file
View File

@@ -0,0 +1,8 @@
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

View File

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

View File

@@ -1,5 +1,6 @@
package com.android.trisolarisserver.component
import com.android.trisolarisserver.repo.AppUserRepo
import com.android.trisolarisserver.repo.PropertyUserRepo
import com.android.trisolarisserver.models.property.Role
import org.springframework.security.access.AccessDeniedException
@@ -8,15 +9,30 @@ import java.util.UUID
@Component
class PropertyAccess(
private val repo: PropertyUserRepo
private val repo: PropertyUserRepo,
private val appUserRepo: AppUserRepo
) {
fun requireMember(propertyId: UUID, userId: UUID) {
if (!repo.existsByIdPropertyIdAndIdUserId(propertyId, userId))
throw AccessDeniedException("No access to property")
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) {
if (!repo.hasAnyRole(propertyId, userId, roles.toSet()))
throw AccessDeniedException("Missing role")
val user = appUserRepo.findById(userId).orElse(null)
if (user == null) {
throw AccessDeniedException("Missing role (user not found)")
}
if (user.superAdmin) return
if (!repo.hasAnyRole(propertyId, userId, roles.toSet())) {
throw AccessDeniedException("Missing role (no matching roles)")
}
}
}

View File

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

View File

@@ -0,0 +1,103 @@
package com.android.trisolarisserver.component
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
import org.springframework.web.multipart.MultipartFile
import java.awt.image.BufferedImage
import java.io.ByteArrayInputStream
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import java.time.OffsetDateTime
import java.util.UUID
import javax.imageio.ImageIO
@Component
class RoomImageStorage(
@Value("\${storage.rooms.root:/home/androidlover5842/docs/rooms}")
private val rootPath: String
) {
fun store(propertyId: UUID, roomId: UUID, file: MultipartFile): StoredRoomImage {
val contentType = file.contentType ?: ""
if (!contentType.startsWith("image/")) {
throw IllegalArgumentException("Only image files are allowed")
}
val bytes = file.bytes
val originalName = file.originalFilename ?: UUID.randomUUID().toString()
val ext = extensionFor(contentType, originalName)
val dir = Paths.get(rootPath, propertyId.toString(), roomId.toString())
Files.createDirectories(dir)
val base = UUID.randomUUID().toString() + "_" + OffsetDateTime.now().toEpochSecond()
val originalPath = dir.resolve("$base.$ext")
val originalTmp = dir.resolve("$base.$ext.tmp")
Files.write(originalTmp, bytes)
atomicMove(originalTmp, originalPath)
val image = readImage(bytes)
?: throw IllegalArgumentException("Unsupported image")
val thumb = resize(image, 320)
val thumbExt = if (ext.lowercase() == "jpg") "jpg" else "png"
val thumbPath = dir.resolve("${base}_thumb.$thumbExt")
val thumbTmp = dir.resolve("${base}_thumb.$thumbExt.tmp")
ByteArrayInputStream(render(thumb, thumbExt)).use { input ->
Files.copy(input, thumbTmp)
}
atomicMove(thumbTmp, thumbPath)
return StoredRoomImage(
originalPath = originalPath.toString(),
thumbnailPath = thumbPath.toString(),
contentType = contentType,
sizeBytes = bytes.size.toLong()
)
}
private fun readImage(bytes: ByteArray): BufferedImage? {
return ByteArrayInputStream(bytes).use { input -> ImageIO.read(input) }
}
private fun resize(input: BufferedImage, maxSize: Int): BufferedImage {
val width = input.width
val height = input.height
if (width <= maxSize && height <= maxSize) return input
val scale = if (width > height) maxSize.toDouble() / width else maxSize.toDouble() / height
val newW = (width * scale).toInt()
val newH = (height * scale).toInt()
val output = BufferedImage(newW, newH, BufferedImage.TYPE_INT_RGB)
val g = output.createGraphics()
g.drawImage(input, 0, 0, newW, newH, null)
g.dispose()
return output
}
private fun render(image: BufferedImage, format: String): ByteArray {
val out = java.io.ByteArrayOutputStream()
ImageIO.write(image, format, out)
return out.toByteArray()
}
private fun extensionFor(contentType: String, filename: String): String {
return when {
contentType.contains("png", true) -> "png"
contentType.contains("jpeg", true) || contentType.contains("jpg", true) -> "jpg"
filename.contains(".") -> filename.substringAfterLast('.').lowercase()
else -> "png"
}
}
private fun atomicMove(tmp: Path, target: Path) {
try {
Files.move(tmp, target, java.nio.file.StandardCopyOption.ATOMIC_MOVE, java.nio.file.StandardCopyOption.REPLACE_EXISTING)
} catch (_: Exception) {
Files.move(tmp, target, java.nio.file.StandardCopyOption.REPLACE_EXISTING)
}
}
}
data class StoredRoomImage(
val originalPath: String,
val thumbnailPath: String,
val contentType: String,
val sizeBytes: Long
)

View File

@@ -0,0 +1,71 @@
package com.android.trisolarisserver.config
import jakarta.servlet.http.HttpServletRequest
import org.springframework.http.HttpStatus
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).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).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).body(
ApiError(
timestamp = OffsetDateTime.now().toString(),
status = HttpStatus.INTERNAL_SERVER_ERROR.value(),
error = HttpStatus.INTERNAL_SERVER_ERROR.reasonPhrase,
message = ex.message ?: "Internal server error",
path = request.requestURI
)
)
}
}
data class ApiError(
val timestamp: String,
val status: Int,
val error: String,
val message: String,
val path: String
)

View File

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

View File

@@ -5,13 +5,20 @@ 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 com.google.firebase.auth.FirebaseAuth
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
@RestController
@RequestMapping("/auth")
@@ -19,21 +26,49 @@ class Auth(
private val appUserRepo: AppUserRepo,
private val propertyUserRepo: PropertyUserRepo
) {
private val logger = LoggerFactory.getLogger(Auth::class.java)
@PostMapping("/verify")
fun verify(@AuthenticationPrincipal principal: MyPrincipal?): AuthResponse {
return buildAuthResponse(principal)
fun verify(
@AuthenticationPrincipal principal: MyPrincipal?,
request: HttpServletRequest
): ResponseEntity<AuthResponse> {
logger.info("Auth verify hit, principalPresent={}", principal != null)
val resolved = principal?.let { ResolveResult(it) } ?: resolvePrincipalFromHeader(request)
return resolved.toResponseEntity()
}
@GetMapping("/me")
fun me(@AuthenticationPrincipal principal: MyPrincipal?): AuthResponse {
return buildAuthResponse(principal)
fun me(
@AuthenticationPrincipal principal: MyPrincipal?,
request: HttpServletRequest
): ResponseEntity<AuthResponse> {
val resolved = principal?.let { ResolveResult(it) } ?: resolvePrincipalFromHeader(request)
return resolved.toResponseEntity()
}
private fun buildAuthResponse(principal: MyPrincipal?): AuthResponse {
if (principal == null) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
@PutMapping("/me")
fun updateMe(
@AuthenticationPrincipal principal: MyPrincipal?,
request: HttpServletRequest,
@RequestBody body: UpdateMeRequest
): ResponseEntity<AuthResponse> {
val resolved = principal?.let { ResolveResult(it) } ?: resolvePrincipalFromHeader(request)
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")
}
@@ -44,21 +79,85 @@ class Auth(
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!!,
orgId = user.org.id!!,
firebaseUid = user.firebaseUid,
phoneE164 = user.phoneE164,
name = user.name,
disabled = user.disabled
disabled = user.disabled,
superAdmin = user.superAdmin
),
properties = memberships
)
}
private fun resolvePrincipalFromHeader(request: HttpServletRequest): ResolveResult {
val header = request.getHeader("Authorization") ?: throw ResponseStatusException(
HttpStatus.UNAUTHORIZED,
"Missing Authorization token"
)
if (!header.startsWith("Bearer ")) {
logger.warn("Auth verify invalid Authorization header")
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid Authorization header")
}
val token = header.removePrefix("Bearer ").trim()
val decoded = try {
FirebaseAuth.getInstance().verifyIdToken(token)
} catch (ex: Exception) {
logger.warn("Auth verify failed: {}", ex.message)
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid token")
}
val user = appUserRepo.findByFirebaseUid(decoded.uid) ?: run {
val phone = decoded.claims["phone_number"] as? String
val name = decoded.claims["name"] as? String
val makeSuperAdmin = appUserRepo.count() == 0L
val created = appUserRepo.save(
com.android.trisolarisserver.models.property.AppUser(
firebaseUid = decoded.uid,
phoneE164 = phone,
name = name,
superAdmin = makeSuperAdmin
)
)
logger.warn("Auth verify auto-created user uid={}, userId={}", decoded.uid, created.id)
created
}
logger.warn("Auth verify resolved uid={}, userId={}", decoded.uid, user.id)
return ResolveResult(
MyPrincipal(
userId = user.id ?: throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "User id missing"),
firebaseUid = decoded.uid
)
)
}
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 user: UserResponse,
val properties: List<PropertyUserResponse>
val status: String,
val user: UserResponse? = null,
val properties: List<PropertyUserResponse> = emptyList()
)
data class UpdateMeRequest(
val name: String? = null
)
private data class ResolveResult(
val principal: MyPrincipal?
)

View File

@@ -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 = if (property.allowedTransportModes.isNotEmpty()) {
property.allowedTransportModes
} else {
TransportMode.entries.toSet()
}
return allowed.contains(mode)
}
private fun requirePrincipal(principal: MyPrincipal?) {
if (principal == null) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
}
}
}

View File

@@ -71,8 +71,8 @@ class GuestDocuments(
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 (guest.property.id != property.id) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Guest not in property")
}
val booking = bookingRepo.findById(bookingId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found")

View File

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

View File

@@ -0,0 +1,126 @@
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 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(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.property.id != property.id) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Guest not in property")
}
if (guestVehicleRepo.existsByPropertyIdAndVehicleNumberIgnoreCase(property.id!!, request.vehicleNumber)) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Vehicle number already exists")
}
val vehicle = GuestVehicle(
property = property,
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!!,
name = guest.name,
phoneE164 = guest.phoneE164,
nationality = guest.nationality,
addressText = guest.addressText,
vehicleNumbers = vehiclesByGuest[guest.id]?.map { it.vehicleNumber }?.toSet() ?: emptySet(),
averageScore = averages[guest.id]
)
}
}

View File

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

View File

@@ -0,0 +1,98 @@
package com.android.trisolarisserver.controller
import com.android.trisolarisserver.component.EmailStorage
import com.android.trisolarisserver.component.PropertyAccess
import com.android.trisolarisserver.db.repo.InboundEmailRepo
import com.android.trisolarisserver.models.booking.InboundEmail
import com.android.trisolarisserver.models.booking.InboundEmailStatus
import com.android.trisolarisserver.models.property.Role
import com.android.trisolarisserver.repo.PropertyRepo
import com.android.trisolarisserver.security.MyPrincipal
import com.android.trisolarisserver.service.EmailIngestionService
import org.apache.pdfbox.pdmodel.PDDocument
import org.apache.pdfbox.text.PDFTextStripper
import org.springframework.http.HttpStatus
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile
import org.springframework.web.server.ResponseStatusException
import java.time.OffsetDateTime
import java.util.UUID
@RestController
@RequestMapping("/properties/{propertyId}/inbound-emails")
class InboundEmailManual(
private val propertyAccess: PropertyAccess,
private val propertyRepo: PropertyRepo,
private val inboundEmailRepo: InboundEmailRepo,
private val emailStorage: EmailStorage,
private val emailIngestionService: EmailIngestionService
) {
@PostMapping("/manual")
@ResponseStatus(HttpStatus.CREATED)
fun uploadManualPdf(
@PathVariable propertyId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestParam("file") file: MultipartFile
): ManualInboundResponse {
requirePrincipal(principal)
propertyAccess.requireMember(propertyId, principal!!.userId)
propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN, Role.MANAGER)
if (file.isEmpty) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "File is empty")
}
val contentType = file.contentType
if (contentType != null && !contentType.equals("application/pdf", ignoreCase = true)) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Only PDF is supported")
}
val property = propertyRepo.findById(propertyId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
}
val bytes = file.bytes
val pdfPath = emailStorage.storeUploadedPdf(propertyId, file.originalFilename, bytes)
val text = extractPdfText(bytes)
val inbound = InboundEmail(
property = property,
messageId = "manual-${UUID.randomUUID()}",
subject = file.originalFilename ?: "manual-upload",
fromAddress = null,
receivedAt = OffsetDateTime.now(),
status = InboundEmailStatus.PENDING,
rawPdfPath = pdfPath
)
inboundEmailRepo.save(inbound)
emailIngestionService.ingestManualPdf(property, inbound, text)
return ManualInboundResponse(inboundId = inbound.id!!)
}
private fun extractPdfText(bytes: ByteArray): String {
return try {
PDDocument.load(bytes).use { doc ->
PDFTextStripper().getText(doc)
}
} catch (_: Exception) {
""
}
}
private fun requirePrincipal(principal: MyPrincipal?) {
if (principal == null) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
}
}
}
data class ManualInboundResponse(
val inboundId: UUID
)

View File

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

View File

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

View File

@@ -1,78 +0,0 @@
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.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
}
val saved = orgRepo.save(org)
return OrgResponse(
id = saved.id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Org id missing"),
name = saved.name ?: ""
)
}
@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 ?: ""
)
}
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")
}
}
}

View File

@@ -6,9 +6,7 @@ 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
@@ -16,6 +14,7 @@ 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
@@ -33,69 +32,61 @@ import java.util.UUID
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")
@PostMapping("/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")
if (propertyRepo.existsByCode(request.code)) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Property code already exists")
}
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()
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("/orgs/{orgId}/properties")
@GetMapping("/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")
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() }
}
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")
@@ -126,8 +117,10 @@ class Properties(
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()
@@ -150,9 +143,6 @@ class Properties(
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),
@@ -198,8 +188,8 @@ class Properties(
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")
if (propertyRepo.existsByCodeAndIdNot(request.code, propertyId)) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Property code already exists")
}
property.code = request.code
@@ -211,6 +201,12 @@ class Properties(
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()
}
@@ -230,38 +226,27 @@ class Properties(
}
}
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()
)
}
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
otaAliases = otaAliases.toSet(),
emailAddresses = emailAddresses.toSet(),
allowedTransportModes = allowedTransportModes.map { it.name }.toSet()
)
}

View File

@@ -0,0 +1,124 @@
package com.android.trisolarisserver.controller
import com.android.trisolarisserver.controller.dto.AmenityResponse
import com.android.trisolarisserver.controller.dto.AmenityUpsertRequest
import com.android.trisolarisserver.models.room.RoomAmenity
import com.android.trisolarisserver.repo.AppUserRepo
import com.android.trisolarisserver.repo.RoomAmenityRepo
import com.android.trisolarisserver.repo.RoomTypeRepo
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("/amenities")
class RoomAmenities(
private val roomAmenityRepo: RoomAmenityRepo,
private val roomTypeRepo: RoomTypeRepo,
private val appUserRepo: AppUserRepo
) {
@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(principal)
if (roomAmenityRepo.existsByName(request.name)) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Amenity already exists")
}
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(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")
}
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)
fun deleteAmenity(
@PathVariable amenityId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
) {
requireSuperAdmin(principal)
val amenity = roomAmenityRepo.findById(amenityId).orElse(null)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Amenity not found")
if (roomTypeRepo.existsByAmenitiesId(amenityId)) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Amenity is used by room types")
}
roomAmenityRepo.delete(amenity)
}
private fun requirePrincipal(principal: MyPrincipal?) {
if (principal == null) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
}
}
private fun requireSuperAdmin(principal: MyPrincipal?) {
if (principal == null) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
}
val user = appUserRepo.findById(principal.userId).orElseThrow {
ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found")
}
if (!user.superAdmin) {
throw ResponseStatusException(HttpStatus.FORBIDDEN, "Super admin only")
}
}
}
private fun RoomAmenity.toResponse(): AmenityResponse {
val id = id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Amenity id missing")
return AmenityResponse(
id = id,
name = name,
category = category,
iconKey = iconKey
)
}

View File

@@ -0,0 +1,139 @@
package com.android.trisolarisserver.controller
import com.android.trisolarisserver.component.PropertyAccess
import com.android.trisolarisserver.component.RoomImageStorage
import com.android.trisolarisserver.controller.dto.RoomImageResponse
import com.android.trisolarisserver.models.room.RoomImage
import com.android.trisolarisserver.models.property.Role
import com.android.trisolarisserver.repo.RoomImageRepo
import com.android.trisolarisserver.repo.RoomRepo
import com.android.trisolarisserver.security.MyPrincipal
import org.springframework.core.io.FileSystemResource
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile
import org.springframework.web.server.ResponseStatusException
import java.nio.file.Files
import java.nio.file.Paths
import java.util.UUID
@RestController
@RequestMapping("/properties/{propertyId}/rooms/{roomId}/images")
class RoomImages(
private val propertyAccess: PropertyAccess,
private val roomRepo: RoomRepo,
private val roomImageRepo: RoomImageRepo,
private val storage: RoomImageStorage,
@org.springframework.beans.factory.annotation.Value("\${storage.rooms.publicBaseUrl}")
private val publicBaseUrl: String
) {
@GetMapping
fun list(
@PathVariable propertyId: UUID,
@PathVariable roomId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
): List<RoomImageResponse> {
requirePrincipal(principal)
propertyAccess.requireMember(propertyId, principal!!.userId)
ensureRoom(propertyId, roomId)
return roomImageRepo.findByRoomIdOrderByCreatedAtDesc(roomId)
.map { it.toResponse(publicBaseUrl) }
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
fun upload(
@PathVariable propertyId: UUID,
@PathVariable roomId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestParam("file") file: MultipartFile
): RoomImageResponse {
requirePrincipal(principal)
propertyAccess.requireMember(propertyId, principal!!.userId)
propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN, Role.MANAGER)
val room = ensureRoom(propertyId, roomId)
if (file.isEmpty) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "File is empty")
}
val stored = try {
storage.store(propertyId, roomId, file)
} catch (ex: IllegalArgumentException) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, ex.message ?: "Invalid image")
}
val image = RoomImage(
property = room.property,
room = room,
originalPath = stored.originalPath,
thumbnailPath = stored.thumbnailPath,
contentType = stored.contentType,
sizeBytes = stored.sizeBytes
)
return roomImageRepo.save(image).toResponse(publicBaseUrl)
}
@GetMapping("/{imageId}/file")
fun file(
@PathVariable propertyId: UUID,
@PathVariable roomId: UUID,
@PathVariable imageId: UUID,
@RequestParam(required = false, defaultValue = "full") size: String,
@AuthenticationPrincipal principal: MyPrincipal?
): ResponseEntity<FileSystemResource> {
requirePrincipal(principal)
propertyAccess.requireMember(propertyId, principal!!.userId)
ensureRoom(propertyId, roomId)
val image = roomImageRepo.findByIdAndRoomIdAndPropertyId(imageId, roomId, propertyId)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Image not found")
val path = if (size.equals("thumb", true)) image.thumbnailPath else image.originalPath
val file = Paths.get(path)
if (!Files.exists(file)) {
throw ResponseStatusException(HttpStatus.NOT_FOUND, "File missing")
}
val resource = FileSystemResource(file)
val type = image.contentType
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(type))
.header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"room-${imageId}.${file.fileName}\"")
.contentLength(resource.contentLength())
.body(resource)
}
private fun ensureRoom(propertyId: UUID, roomId: UUID): com.android.trisolarisserver.models.room.Room {
return roomRepo.findByIdAndPropertyId(roomId, propertyId)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room not found")
}
private fun requirePrincipal(principal: MyPrincipal?) {
if (principal == null) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
}
}
}
private fun RoomImage.toResponse(baseUrl: String): RoomImageResponse {
val id = id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Image id missing")
return RoomImageResponse(
id = id,
propertyId = property.id!!,
roomId = room.id!!,
url = "$baseUrl/properties/${property.id}/rooms/${room.id}/images/$id/file",
thumbnailUrl = "$baseUrl/properties/${property.id}/rooms/${room.id}/images/$id/file?size=thumb",
contentType = contentType,
sizeBytes = sizeBytes,
createdAt = createdAt.toString()
)
}

View File

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

View File

@@ -0,0 +1,73 @@
package com.android.trisolarisserver.controller
import com.android.trisolarisserver.component.PropertyAccess
import com.android.trisolarisserver.controller.dto.ActiveRoomStayResponse
import com.android.trisolarisserver.models.property.Role
import com.android.trisolarisserver.repo.PropertyUserRepo
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.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.server.ResponseStatusException
import java.util.UUID
@RestController
class RoomStays(
private val propertyAccess: PropertyAccess,
private val propertyUserRepo: PropertyUserRepo,
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()
)
}
}
private fun isAgentOnly(roles: Set<Role>): Boolean {
if (!roles.contains(Role.AGENT)) return false
val privileged = setOf(
Role.ADMIN,
Role.MANAGER,
Role.STAFF,
Role.HOUSEKEEPING,
Role.FINANCE,
Role.SUPERVISOR,
Role.GUIDE
)
return roles.none { it in privileged }
}
}

View File

@@ -4,9 +4,11 @@ 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.RoomAmenityRepo
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.RoomAmenity
import com.android.trisolarisserver.models.room.RoomType
import com.android.trisolarisserver.security.MyPrincipal
import org.springframework.http.HttpStatus
@@ -28,6 +30,7 @@ import java.util.UUID
class RoomTypes(
private val propertyAccess: PropertyAccess,
private val roomTypeRepo: RoomTypeRepo,
private val roomAmenityRepo: RoomAmenityRepo,
private val roomRepo: RoomRepo,
private val propertyRepo: PropertyRepo
) {
@@ -66,11 +69,27 @@ class RoomTypes(
name = request.name,
baseOccupancy = request.baseOccupancy ?: 2,
maxOccupancy = request.maxOccupancy ?: 3,
sqFeet = request.sqFeet,
bathroomSqFeet = request.bathroomSqFeet,
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,
@@ -93,9 +112,14 @@ class RoomTypes(
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
if (request.otaAliases != null) {
roomType.otaAliases = request.otaAliases.toMutableSet()
}
if (request.amenityIds != null) {
roomType.amenities = resolveAmenities(request.amenityIds)
}
return roomTypeRepo.save(roomType).toResponse()
}
@@ -136,6 +160,19 @@ private fun RoomType.toResponse(): RoomTypeResponse {
name = name,
baseOccupancy = baseOccupancy,
maxOccupancy = maxOccupancy,
otaAliases = otaAliases.toSet()
sqFeet = sqFeet,
bathroomSqFeet = bathroomSqFeet,
otaAliases = otaAliases.toSet(),
amenities = amenities.map { it.toResponse() }.toSet()
)
}
private fun RoomAmenity.toResponse(): com.android.trisolarisserver.controller.dto.AmenityResponse {
val id = id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Amenity id missing")
return com.android.trisolarisserver.controller.dto.AmenityResponse(
id = id,
name = name,
category = category,
iconKey = iconKey
)
}

View File

@@ -1,6 +1,8 @@
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
@@ -25,6 +27,9 @@ 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
@@ -35,7 +40,8 @@ class Rooms(
private val roomStayRepo: RoomStayRepo,
private val propertyRepo: PropertyRepo,
private val roomTypeRepo: RoomTypeRepo,
private val propertyUserRepo: PropertyUserRepo
private val propertyUserRepo: PropertyUserRepo,
private val roomBoardEvents: RoomBoardEvents
) {
@GetMapping
@@ -85,6 +91,16 @@ class Rooms(
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,
@@ -106,6 +122,43 @@ class Rooms(
}.sortedBy { it.roomTypeName }
}
@GetMapping("/availability-range")
fun roomAvailabilityRange(
@PathVariable propertyId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@org.springframework.web.bind.annotation.RequestParam("from") from: String,
@org.springframework.web.bind.annotation.RequestParam("to") to: String
): List<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(
@@ -123,8 +176,7 @@ class Rooms(
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 roomType = resolveRoomType(propertyId, request)
val room = Room(
property = property,
@@ -137,7 +189,19 @@ class Rooms(
notes = request.notes
)
return roomRepo.save(room).toRoomResponse()
val saved = roomRepo.save(room)
val response = RoomResponse(
id = saved.id ?: throw IllegalStateException("Room id is null"),
roomNumber = saved.roomNumber,
floor = saved.floor,
roomTypeName = roomType.name,
hasNfc = saved.hasNfc,
active = saved.active,
maintenance = saved.maintenance,
notes = saved.notes
)
roomBoardEvents.emit(propertyId)
return response
}
@PutMapping("/{roomId}")
@@ -157,8 +221,7 @@ class Rooms(
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")
val roomType = resolveRoomType(propertyId, request)
room.roomNumber = request.roomNumber
room.floor = request.floor
@@ -168,7 +231,19 @@ class Rooms(
room.maintenance = request.maintenance
room.notes = request.notes
return roomRepo.save(room).toRoomResponse()
val saved = roomRepo.save(room)
val response = RoomResponse(
id = saved.id ?: throw IllegalStateException("Room id is null"),
roomNumber = saved.roomNumber,
floor = saved.floor,
roomTypeName = roomType.name,
hasNfc = saved.hasNfc,
active = saved.active,
maintenance = saved.maintenance,
notes = saved.notes
)
roomBoardEvents.emit(propertyId)
return response
}
private fun requirePrincipal(principal: MyPrincipal?) {
@@ -182,16 +257,31 @@ class Rooms(
val privileged = setOf(Role.ADMIN, Role.MANAGER, Role.STAFF, Role.HOUSEKEEPING, Role.FINANCE)
return roles.none { it in privileged }
}
private fun parseDate(value: String): LocalDate {
return try {
LocalDate.parse(value.trim())
} catch (_: Exception) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid date format")
}
}
private fun resolveRoomType(propertyId: UUID, request: RoomUpsertRequest): com.android.trisolarisserver.models.room.RoomType {
val code = request.roomTypeCode.trim()
if (code.isBlank()) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "roomTypeCode required")
}
return roomTypeRepo.findByPropertyIdAndCodeIgnoreCase(propertyId, code)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room type not found")
}
}
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,

View File

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

View File

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

View File

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

View File

@@ -2,15 +2,6 @@ package com.android.trisolarisserver.controller.dto
import java.util.UUID
data class OrgCreateRequest(
val name: String
)
data class OrgResponse(
val id: UUID,
val name: String
)
data class PropertyCreateRequest(
val code: String,
val name: String,
@@ -18,7 +9,9 @@ data class PropertyCreateRequest(
val timezone: String? = null,
val currency: String? = null,
val active: Boolean? = null,
val otaAliases: Set<String>? = null
val otaAliases: Set<String>? = null,
val emailAddresses: Set<String>? = null,
val allowedTransportModes: Set<String>? = null
)
data class PropertyUpdateRequest(
@@ -28,28 +21,50 @@ data class PropertyUpdateRequest(
val timezone: String? = null,
val currency: String? = null,
val active: Boolean? = null,
val otaAliases: Set<String>? = null
val otaAliases: Set<String>? = null,
val emailAddresses: Set<String>? = null,
val allowedTransportModes: Set<String>? = null
)
data class PropertyResponse(
val id: UUID,
val orgId: UUID,
val code: String,
val name: String,
val addressText: String?,
val timezone: String,
val currency: String,
val active: Boolean,
val otaAliases: Set<String>
val otaAliases: Set<String>,
val emailAddresses: Set<String>,
val allowedTransportModes: Set<String>
)
data class GuestResponse(
val id: UUID,
val name: String?,
val phoneE164: String?,
val nationality: String?,
val addressText: String?,
val vehicleNumbers: Set<String>,
val averageScore: Double?
)
data class GuestVehicleRequest(
val vehicleNumber: String
)
data class TransportModeStatusResponse(
val mode: String,
val enabled: Boolean
)
data class UserResponse(
val id: UUID,
val orgId: UUID,
val firebaseUid: String?,
val phoneE164: String?,
val name: String?,
val disabled: Boolean
val disabled: Boolean,
val superAdmin: Boolean
)
data class PropertyUserRoleRequest(

View File

@@ -6,7 +6,6 @@ data class RoomResponse(
val id: UUID,
val roomNumber: Int,
val floor: Int?,
val roomTypeId: UUID,
val roomTypeName: String,
val hasNfc: Boolean,
val active: Boolean,
@@ -25,6 +24,23 @@ data class RoomAvailabilityResponse(
val freeRoomNumbers: List<Int>
)
data class RoomAvailabilityRangeResponse(
val roomTypeName: String,
val freeRoomNumbers: List<Int>,
val freeCount: Int
)
data class RoomImageResponse(
val id: UUID,
val propertyId: UUID,
val roomId: UUID,
val url: String,
val thumbnailUrl: String,
val contentType: String,
val sizeBytes: Long,
val createdAt: String
)
enum class RoomBoardStatus {
FREE,
OCCUPIED,
@@ -35,7 +51,7 @@ enum class RoomBoardStatus {
data class RoomUpsertRequest(
val roomNumber: Int,
val floor: Int?,
val roomTypeId: UUID,
val roomTypeCode: String,
val hasNfc: Boolean,
val active: Boolean,
val maintenance: Boolean,

View File

@@ -0,0 +1,17 @@
package com.android.trisolarisserver.controller.dto
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?
)

View File

@@ -7,7 +7,10 @@ data class RoomTypeUpsertRequest(
val name: String,
val baseOccupancy: Int? = null,
val maxOccupancy: Int? = null,
val otaAliases: Set<String>? = null
val sqFeet: Int? = null,
val bathroomSqFeet: Int? = null,
val otaAliases: Set<String>? = null,
val amenityIds: Set<UUID>? = null
)
data class RoomTypeResponse(
@@ -17,5 +20,21 @@ data class RoomTypeResponse(
val name: String,
val baseOccupancy: Int,
val maxOccupancy: Int,
val otaAliases: Set<String>
val sqFeet: Int?,
val bathroomSqFeet: Int?,
val otaAliases: Set<String>,
val amenities: Set<AmenityResponse>
)
data class AmenityUpsertRequest(
val name: String,
val category: String? = null,
val iconKey: String? = null
)
data class AmenityResponse(
val id: UUID,
val name: String,
val category: String?,
val iconKey: String?
)

View File

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

View File

@@ -5,5 +5,5 @@ import org.springframework.data.jpa.repository.JpaRepository
import java.util.UUID
interface GuestRepo : JpaRepository<Guest, UUID> {
fun findByOrgIdAndPhoneE164(orgId: UUID, phoneE164: String): Guest?
fun findByPropertyIdAndPhoneE164(propertyId: UUID, phoneE164: String): Guest?
}

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
package com.android.trisolarisserver.models.booking
import com.android.trisolarisserver.models.property.Organization
import com.android.trisolarisserver.models.property.Property
import jakarta.persistence.*
import java.time.OffsetDateTime
import java.util.UUID
@@ -8,7 +8,7 @@ import java.util.UUID
@Entity
@Table(
name = "guest",
uniqueConstraints = [UniqueConstraint(columnNames = ["org_id", "phone_e164"])]
uniqueConstraints = [UniqueConstraint(columnNames = ["property_id", "phone_e164"])]
)
class Guest(
@Id
@@ -17,8 +17,8 @@ class Guest(
val id: UUID? = null,
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "org_id", nullable = false)
var org: Organization,
@JoinColumn(name = "property_id", nullable = false)
var property: Property,
@Column(name = "phone_e164")
var phoneE164: String? = null,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,11 @@
package com.android.trisolarisserver.models.property
import jakarta.persistence.*
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.GeneratedValue
import jakarta.persistence.Id
import jakarta.persistence.Table
import jakarta.persistence.UniqueConstraint
import java.time.OffsetDateTime
import java.util.UUID
@@ -15,10 +20,6 @@ class AppUser(
@Column(columnDefinition = "uuid")
val id: UUID? = null,
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "org_id", nullable = false)
var org: Organization,
@Column(name = "firebase_uid")
var firebaseUid: String? = null, // optional if using firebase
@@ -27,6 +28,9 @@ class AppUser(
var name: String? = null,
@Column(name = "is_super_admin", nullable = false)
var superAdmin: Boolean = false,
@Column(name = "is_disabled", nullable = false)
var disabled: Boolean = false,

View File

@@ -1,18 +0,0 @@
package com.android.trisolarisserver.models.property
import jakarta.persistence.*
import java.time.OffsetDateTime
import java.util.*
@Entity
@Table(name = "organization")
class Organization {
@Id
@GeneratedValue
@Column(columnDefinition = "uuid")
val id: UUID? = null
@Column(nullable = false)
var name: String? = null
@Column(name = "created_at", nullable = false, columnDefinition = "timestamptz")
val createdAt: OffsetDateTime = OffsetDateTime.now() }

View File

@@ -1,13 +1,24 @@
package com.android.trisolarisserver.models.property
import jakarta.persistence.*
import jakarta.persistence.Column
import jakarta.persistence.ElementCollection
import jakarta.persistence.Entity
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
import jakarta.persistence.FetchType
import jakarta.persistence.GeneratedValue
import jakarta.persistence.Id
import jakarta.persistence.JoinColumn
import jakarta.persistence.CollectionTable
import jakarta.persistence.Table
import jakarta.persistence.UniqueConstraint
import java.time.OffsetDateTime
import java.util.UUID
@Entity
@Table(
name = "property",
uniqueConstraints = [UniqueConstraint(columnNames = ["org_id", "code"])]
uniqueConstraints = [UniqueConstraint(columnNames = ["code"])]
)
class Property(
@Id
@@ -15,10 +26,6 @@ class Property(
@Column(columnDefinition = "uuid")
val id: UUID? = null,
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "org_id", nullable = false)
var org: Organization,
@Column(nullable = false)
var code: String, // "TRI-VNS"
@@ -37,6 +44,14 @@ class Property(
@Column(name = "address_text")
var addressText: String? = null,
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(
name = "property_email_address",
joinColumns = [JoinColumn(name = "property_id")]
)
@Column(name = "email", nullable = false)
var emailAddresses: MutableSet<String> = mutableSetOf(),
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(
name = "property_email_alias",
@@ -45,6 +60,16 @@ class Property(
@Column(name = "alias", nullable = false)
var otaAliases: MutableSet<String> = mutableSetOf(),
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(
name = "property_transport_mode",
joinColumns = [JoinColumn(name = "property_id")]
)
@Column(name = "mode", nullable = false)
@Enumerated(EnumType.STRING)
var allowedTransportModes: MutableSet<com.android.trisolarisserver.models.booking.TransportMode> =
mutableSetOf(),
@Column(name = "created_at", nullable = false, columnDefinition = "timestamptz")
val createdAt: OffsetDateTime = OffsetDateTime.now()
)

View File

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

View File

@@ -0,0 +1,28 @@
package com.android.trisolarisserver.models.room
import com.android.trisolarisserver.models.property.Property
import jakarta.persistence.*
import java.time.OffsetDateTime
import java.util.UUID
@Entity
@Table(
name = "property_card_counter",
uniqueConstraints = [UniqueConstraint(columnNames = ["property_id"])]
)
class PropertyCardCounter(
@Id
@GeneratedValue
@Column(columnDefinition = "uuid")
val id: UUID? = null,
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "property_id", nullable = false)
var property: Property,
@Column(name = "next_index", nullable = false)
var nextIndex: Int = 1,
@Column(name = "updated_at", nullable = false, columnDefinition = "timestamptz")
var updatedAt: OffsetDateTime = OffsetDateTime.now()
)

View File

@@ -0,0 +1,34 @@
package com.android.trisolarisserver.models.room
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.GeneratedValue
import jakarta.persistence.Id
import jakarta.persistence.Table
import jakarta.persistence.UniqueConstraint
import java.time.OffsetDateTime
import java.util.UUID
@Entity
@Table(
name = "room_amenity",
uniqueConstraints = [UniqueConstraint(columnNames = ["name"])]
)
class RoomAmenity(
@Id
@GeneratedValue
@Column(columnDefinition = "uuid")
val id: UUID? = null,
@Column(nullable = false)
var name: String,
@Column
var category: String? = null,
@Column(name = "icon_key")
var iconKey: String? = null,
@Column(name = "created_at", nullable = false, columnDefinition = "timestamptz")
val createdAt: OffsetDateTime = OffsetDateTime.now()
)

View File

@@ -0,0 +1,38 @@
package com.android.trisolarisserver.models.room
import com.android.trisolarisserver.models.property.Property
import jakarta.persistence.*
import java.time.OffsetDateTime
import java.util.UUID
@Entity
@Table(name = "room_image")
class RoomImage(
@Id
@GeneratedValue
@Column(columnDefinition = "uuid")
val id: UUID? = null,
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "property_id", nullable = false)
var property: Property,
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "room_id", nullable = false)
var room: Room,
@Column(name = "original_path", nullable = false)
var originalPath: String,
@Column(name = "thumbnail_path", nullable = false)
var thumbnailPath: String,
@Column(name = "content_type", nullable = false)
var contentType: String,
@Column(name = "size_bytes", nullable = false)
var sizeBytes: Long,
@Column(name = "created_at", nullable = false, columnDefinition = "timestamptz")
val createdAt: OffsetDateTime = OffsetDateTime.now()
)

View File

@@ -0,0 +1,36 @@
package com.android.trisolarisserver.models.room
import com.android.trisolarisserver.models.property.Property
import jakarta.persistence.*
import java.time.OffsetDateTime
import java.util.UUID
@Entity
@Table(
name = "room_stay_change",
uniqueConstraints = [UniqueConstraint(columnNames = ["room_stay_id", "idempotency_key"])]
)
class RoomStayChange(
@Id
@GeneratedValue
@Column(columnDefinition = "uuid")
val id: UUID? = null,
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "property_id", nullable = false)
var property: Property,
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "room_stay_id", nullable = false)
var roomStay: RoomStay,
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "new_room_stay_id", nullable = false)
var newRoomStay: RoomStay,
@Column(name = "idempotency_key", nullable = false)
var idempotencyKey: String,
@Column(name = "created_at", nullable = false, columnDefinition = "timestamptz")
val createdAt: OffsetDateTime = OffsetDateTime.now()
)

View File

@@ -32,6 +32,12 @@ class RoomType(
@Column(name = "max_occupancy", nullable = false)
var maxOccupancy: Int = 3,
@Column(name = "sq_feet")
var sqFeet: Int? = null,
@Column(name = "bathroom_sq_feet")
var bathroomSqFeet: Int? = null,
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(
name = "room_type_alias",
@@ -40,6 +46,14 @@ class RoomType(
@Column(name = "alias", nullable = false)
var otaAliases: MutableSet<String> = mutableSetOf(),
@ManyToMany(fetch = FetchType.LAZY)
@JoinTable(
name = "room_type_amenity_link",
joinColumns = [JoinColumn(name = "room_type_id")],
inverseJoinColumns = [JoinColumn(name = "amenity_id")]
)
var amenities: MutableSet<RoomAmenity> = mutableSetOf(),
@Column(name = "created_at", nullable = false, columnDefinition = "timestamptz")
val createdAt: OffsetDateTime = OffsetDateTime.now()
)

View File

@@ -7,5 +7,4 @@ import java.util.UUID
interface AppUserRepo : JpaRepository<AppUser, UUID> {
fun findByFirebaseUid(firebaseUid: String): AppUser?
fun existsByFirebaseUid(firebaseUid: String): Boolean
fun findByOrgId(orgId: UUID): List<AppUser>
}

View File

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

View File

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

View File

@@ -1,11 +0,0 @@
package com.android.trisolarisserver.repo
import com.android.trisolarisserver.models.property.Organization
import org.springframework.data.jpa.repository.JpaRepository
import java.util.UUID
interface OrganizationRepo : JpaRepository<Organization, UUID> {
fun findByName(name: String): Organization?
fun findByNameIgnoreCase(name: String): Organization?
fun existsByNameIgnoreCase(name: String): Boolean
}

View File

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

View File

@@ -5,6 +5,6 @@ import org.springframework.data.jpa.repository.JpaRepository
import java.util.UUID
interface PropertyRepo : JpaRepository<Property, UUID> {
fun existsByOrgIdAndCode(orgId: UUID, code: String): Boolean
fun existsByOrgIdAndCodeAndIdNot(orgId: UUID, code: String, id: UUID): Boolean
fun existsByCode(code: String): Boolean
fun existsByCodeAndIdNot(code: String, id: UUID): Boolean
}

View File

@@ -25,16 +25,6 @@ interface PropertyUserRepo : JpaRepository<PropertyUser, PropertyUserId> {
@Param("userId") userId: UUID
): Set<Role>
@Query("""
select pu.property.id
from PropertyUser pu
where pu.user.id = :userId
and pu.property.org.id = :orgId
""")
fun findPropertyIdsByOrgAndUser(
@Param("orgId") orgId: UUID,
@Param("userId") userId: UUID
): List<UUID>
@Query("""
select case when count(pu) > 0 then true else false end
@@ -49,16 +39,4 @@ interface PropertyUserRepo : JpaRepository<PropertyUser, PropertyUserId> {
@Param("roles") roles: Set<Role>
): Boolean
@Query("""
select case when count(pu) > 0 then true else false end
from PropertyUser pu join pu.roles r
where pu.user.id = :userId
and pu.property.org.id = :orgId
and r in :roles
""")
fun hasAnyRoleInOrg(
@Param("orgId") orgId: UUID,
@Param("userId") userId: UUID,
@Param("roles") roles: Set<Role>
): Boolean
}

View File

@@ -0,0 +1,12 @@
package com.android.trisolarisserver.repo
import com.android.trisolarisserver.models.room.RoomAmenity
import org.springframework.data.jpa.repository.JpaRepository
import java.util.UUID
interface RoomAmenityRepo : JpaRepository<RoomAmenity, UUID> {
fun findAllByOrderByName(): List<RoomAmenity>
fun findByIdIn(ids: Set<UUID>): List<RoomAmenity>
fun existsByName(name: String): Boolean
fun existsByNameAndIdNot(name: String, id: UUID): Boolean
}

View File

@@ -0,0 +1,10 @@
package com.android.trisolarisserver.repo
import com.android.trisolarisserver.models.room.RoomImage
import org.springframework.data.jpa.repository.JpaRepository
import java.util.UUID
interface RoomImageRepo : JpaRepository<RoomImage, UUID> {
fun findByRoomIdOrderByCreatedAtDesc(roomId: UUID): List<RoomImage>
fun findByIdAndRoomIdAndPropertyId(id: UUID, roomId: UUID, propertyId: UUID): RoomImage?
}

View File

@@ -10,9 +10,10 @@ import java.util.UUID
interface RoomRepo : JpaRepository<Room, UUID> {
@EntityGraph(attributePaths = ["roomType"])
@EntityGraph(attributePaths = ["roomType", "roomType.otaAliases", "roomType.amenities"])
fun findByPropertyIdOrderByRoomNumber(propertyId: UUID): List<Room>
@EntityGraph(attributePaths = ["roomType", "roomType.otaAliases", "roomType.amenities"])
fun findByIdAndPropertyId(id: UUID, propertyId: UUID): Room?
fun existsByPropertyIdAndRoomNumber(propertyId: UUID, roomNumber: Int): Boolean

View File

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

View File

@@ -14,4 +14,65 @@ interface RoomStayRepo : JpaRepository<RoomStay, UUID> {
and rs.toAt is null
""")
fun findOccupiedRoomIds(@Param("propertyId") propertyId: UUID): List<UUID>
@Query("""
select distinct rs.room.id
from RoomStay rs
where rs.property.id = :propertyId
and rs.fromAt < :toAt
and (rs.toAt is null or rs.toAt > :fromAt)
""")
fun findOccupiedRoomIdsBetween(
@Param("propertyId") propertyId: UUID,
@Param("fromAt") fromAt: java.time.OffsetDateTime,
@Param("toAt") toAt: java.time.OffsetDateTime
): List<UUID>
@Query("""
select rs
from RoomStay rs
where rs.booking.id = :bookingId
and rs.toAt is null
""")
fun findActiveByBookingId(@Param("bookingId") bookingId: UUID): List<RoomStay>
@Query("""
select rs.room.id
from RoomStay rs
where rs.property.id = :propertyId
and rs.room.id in :roomIds
and rs.toAt is null
""")
fun findActiveRoomIds(
@Param("propertyId") propertyId: UUID,
@Param("roomIds") roomIds: List<UUID>
): List<UUID>
@Query("""
select case when count(rs) > 0 then true else false end
from RoomStay rs
where rs.property.id = :propertyId
and rs.room.id = :roomId
and rs.fromAt < :toAt
and (rs.toAt is null or rs.toAt > :fromAt)
""")
fun existsOverlap(
@Param("propertyId") propertyId: UUID,
@Param("roomId") roomId: UUID,
@Param("fromAt") fromAt: java.time.OffsetDateTime,
@Param("toAt") toAt: java.time.OffsetDateTime
): Boolean
@Query("""
select rs
from RoomStay rs
join fetch rs.room r
join fetch r.roomType rt
join fetch rs.booking b
left join fetch b.primaryGuest g
where rs.property.id = :propertyId
and rs.toAt is null
order by r.roomNumber
""")
fun findActiveByPropertyIdWithDetails(@Param("propertyId") propertyId: UUID): List<RoomStay>
}

View File

@@ -6,9 +6,12 @@ import org.springframework.data.jpa.repository.JpaRepository
import java.util.UUID
interface RoomTypeRepo : JpaRepository<RoomType, UUID> {
@EntityGraph(attributePaths = ["property", "otaAliases", "amenities"])
fun findByIdAndPropertyId(id: UUID, propertyId: UUID): RoomType?
@EntityGraph(attributePaths = ["property"])
fun findByPropertyIdAndCodeIgnoreCase(propertyId: UUID, code: String): RoomType?
@EntityGraph(attributePaths = ["property", "otaAliases", "amenities"])
fun findByPropertyIdOrderByCode(propertyId: UUID): List<RoomType>
fun existsByPropertyIdAndCode(propertyId: UUID, code: String): Boolean
fun existsByPropertyIdAndCodeAndIdNot(propertyId: UUID, code: String, id: UUID): Boolean
fun existsByAmenitiesId(id: UUID): Boolean
}

View File

@@ -5,6 +5,7 @@ import com.google.firebase.auth.FirebaseAuth
import jakarta.servlet.FilterChain
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.slf4j.LoggerFactory
import org.springframework.http.HttpHeaders
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.context.SecurityContextHolder
@@ -17,6 +18,12 @@ import org.springframework.http.HttpStatus
class FirebaseAuthFilter(
private val appUserRepo: AppUserRepo
) : OncePerRequestFilter() {
private val logger = LoggerFactory.getLogger(FirebaseAuthFilter::class.java)
override fun shouldNotFilter(request: HttpServletRequest): Boolean {
val path = request.requestURI
return path == "/" || path == "/health" || path.startsWith("/auth/")
}
override fun doFilterInternal(
request: HttpServletRequest,
@@ -25,6 +32,7 @@ class FirebaseAuthFilter(
) {
val header = request.getHeader(HttpHeaders.AUTHORIZATION)
if (header.isNullOrBlank() || !header.startsWith("Bearer ")) {
logger.debug("Auth missing/invalid header for {}", request.requestURI)
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Missing Authorization token")
return
}
@@ -34,6 +42,7 @@ class FirebaseAuthFilter(
val firebaseUid = decoded.uid
val user = appUserRepo.findByFirebaseUid(firebaseUid)
?: throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found")
logger.debug("Auth verified uid={}, userId={}", firebaseUid, user.id)
val principal = MyPrincipal(
userId = user.id ?: throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "User id missing"),
@@ -43,6 +52,7 @@ class FirebaseAuthFilter(
SecurityContextHolder.getContext().authentication = auth
filterChain.doFilter(request, response)
} catch (ex: Exception) {
logger.debug("Auth failed for {}: {}", request.requestURI, ex.message)
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid token")
}
}

View File

@@ -2,22 +2,61 @@ package com.android.trisolarisserver.security
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.http.SessionCreationPolicy
import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
import org.springframework.security.web.authentication.HttpStatusEntryPoint
import org.springframework.http.HttpStatus
import com.fasterxml.jackson.databind.ObjectMapper
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
@Configuration
@Configuration(proxyBeanMethods = false)
@EnableMethodSecurity
class SecurityConfig(
private val firebaseAuthFilter: FirebaseAuthFilter
private val firebaseAuthFilter: FirebaseAuthFilter,
private val objectMapper: ObjectMapper
) {
@Bean
fun filterChain(http: HttpSecurity): SecurityFilterChain {
http
.csrf { it.disable() }
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
.authorizeHttpRequests { it.anyRequest().authenticated() }
.authorizeHttpRequests {
it.requestMatchers("/", "/health", "/auth/**").permitAll()
it.anyRequest().authenticated()
}
.exceptionHandling {
it.authenticationEntryPoint { request, response, _ ->
writeError(response, request, HttpStatus.UNAUTHORIZED, "Unauthorized")
}
it.accessDeniedHandler { request, response, _ ->
writeError(response, request, HttpStatus.FORBIDDEN, "Forbidden")
}
}
.httpBasic { it.disable() }
.formLogin { it.disable() }
.addFilterBefore(firebaseAuthFilter, UsernamePasswordAuthenticationFilter::class.java)
return http.build()
}
private fun writeError(
response: HttpServletResponse,
request: HttpServletRequest,
status: HttpStatus,
message: String
) {
if (response.isCommitted) return
response.status = status.value()
response.contentType = "application/json"
val body = mapOf(
"status" to status.value(),
"error" to status.reasonPhrase,
"message" to message,
"path" to request.requestURI
)
response.writer.use { it.write(objectMapper.writeValueAsString(body)) }
}
}

View File

@@ -49,6 +49,8 @@ class EmailIngestionService(
@Value("\${mail.imap.protocol:imaps}")
private val protocol: String
) {
@Value("\${storage.emails.publicBaseUrl}")
private val publicBaseUrl: String = ""
private val running = AtomicBoolean(false)
@Value("\${mail.imap.enabled:false}")
private val enabled: Boolean = false
@@ -86,9 +88,10 @@ class EmailIngestionService(
val subject = message.subject
val from = message.from?.firstOrNull()?.toString()
val recipients = extractRecipients(message)
val receivedAt = message.receivedDate?.toInstant()?.atOffset(OffsetDateTime.now().offset)
val body = extractText(message)
val property = matchProperty(subject, body)
val property = matchProperty(subject, body, recipients)
val inbound = InboundEmail(
property = property,
@@ -98,6 +101,10 @@ class EmailIngestionService(
receivedAt = receivedAt
)
val rawBytes = extractRawMessage(message)
if (rawBytes != null) {
inbound.rawEmlPath = emailStorage.storeEml(property?.id, messageId, rawBytes)
}
inbound.rawPdfPath = emailStorage.storePdf(property?.id, messageId, subject, body)
inboundEmailRepo.save(inbound)
@@ -108,56 +115,7 @@ class EmailIngestionService(
return
}
val extracted = extractBookingDetails(body)
inbound.extractedData = objectMapper.writeValueAsString(extracted)
val otaBookingId = extracted["otaBookingId"]?.takeIf { !it.contains("NONE", true) }
if (!otaBookingId.isNullOrBlank() &&
inboundEmailRepo.existsByPropertyIdAndOtaBookingId(property.id!!, otaBookingId)
) {
inbound.status = InboundEmailStatus.SKIPPED
inbound.processedAt = OffsetDateTime.now()
inboundEmailRepo.save(inbound)
return
}
inbound.otaBookingId = otaBookingId
inboundEmailRepo.save(inbound)
val isCancel = extracted["isCancel"]?.contains("YES", ignoreCase = true) == true
if (isCancel) {
if (otaBookingId.isNullOrBlank()) {
inbound.status = InboundEmailStatus.SKIPPED
inbound.processedAt = OffsetDateTime.now()
inboundEmailRepo.save(inbound)
return
}
val booking = bookingRepo.findByPropertyIdAndSourceBookingId(property.id!!, otaBookingId)
if (booking != null) {
booking.status = BookingStatus.CANCELLED
bookingRepo.save(booking)
inbound.booking = booking
}
inbound.status = InboundEmailStatus.CANCELLED
inbound.processedAt = OffsetDateTime.now()
inboundEmailRepo.save(inbound)
return
}
val sourceBookingId = otaBookingId ?: "email:$messageId"
if (bookingRepo.existsByPropertyIdAndSourceBookingId(property.id!!, sourceBookingId)) {
inbound.status = InboundEmailStatus.SKIPPED
inbound.processedAt = OffsetDateTime.now()
inboundEmailRepo.save(inbound)
return
}
val guest = resolveGuest(property, extracted)
val booking = createBooking(property, guest, extracted, sourceBookingId)
inbound.booking = booking
inbound.status = InboundEmailStatus.CREATED
inbound.processedAt = OffsetDateTime.now()
inboundEmailRepo.save(inbound)
handleExtracted(property, inbound, body, "email:$messageId")
}
private fun extractBookingDetails(body: String): Map<String, String> {
@@ -199,11 +157,11 @@ class EmailIngestionService(
private fun resolveGuest(property: Property, extracted: Map<String, String>): Guest {
val phone = extracted["guestPhone"]?.takeIf { !it.contains("NONE", true) }?.trim()
if (!phone.isNullOrBlank()) {
val existing = guestRepo.findByOrgIdAndPhoneE164(property.org.id!!, phone)
val existing = guestRepo.findByPropertyIdAndPhoneE164(property.id!!, phone)
if (existing != null) return existing
}
val guest = Guest(
org = property.org,
property = property,
phoneE164 = phone,
name = extracted["guestName"]?.takeIf { !it.contains("NONE", true) }
)
@@ -214,7 +172,8 @@ class EmailIngestionService(
property: Property,
guest: Guest,
extracted: Map<String, String>,
sourceBookingId: String
sourceBookingId: String,
emailAuditPdfUrl: String?
): Booking {
val zone = ZoneId.of(property.timezone)
val checkin = parsedDate(extracted["checkinDate"], zone)
@@ -226,11 +185,71 @@ class EmailIngestionService(
source = extracted["otaSource"]?.takeIf { !it.contains("NONE", true) } ?: "OTA",
sourceBookingId = sourceBookingId,
expectedCheckinAt = checkin,
expectedCheckoutAt = checkout
expectedCheckoutAt = checkout,
emailAuditPdfUrl = emailAuditPdfUrl
)
return bookingRepo.save(booking)
}
fun ingestManualPdf(property: Property, inbound: InboundEmail, body: String) {
inboundEmailRepo.save(inbound)
handleExtracted(property, inbound, body, "manual:${inbound.id}")
}
private fun handleExtracted(property: Property, inbound: InboundEmail, body: String, fallbackKey: String) {
val extracted = extractBookingDetails(body)
inbound.extractedData = objectMapper.writeValueAsString(extracted)
val otaBookingId = extracted["otaBookingId"]?.takeIf { !it.contains("NONE", true) }
if (!otaBookingId.isNullOrBlank() &&
inboundEmailRepo.existsByPropertyIdAndOtaBookingId(property.id!!, otaBookingId)
) {
inbound.status = InboundEmailStatus.SKIPPED
inbound.processedAt = OffsetDateTime.now()
inboundEmailRepo.save(inbound)
return
}
inbound.otaBookingId = otaBookingId
inboundEmailRepo.save(inbound)
val isCancel = extracted["isCancel"]?.contains("YES", ignoreCase = true) == true
if (isCancel) {
if (otaBookingId.isNullOrBlank()) {
inbound.status = InboundEmailStatus.SKIPPED
inbound.processedAt = OffsetDateTime.now()
inboundEmailRepo.save(inbound)
return
}
val booking = bookingRepo.findByPropertyIdAndSourceBookingId(property.id!!, otaBookingId)
if (booking != null) {
booking.status = BookingStatus.CANCELLED
bookingRepo.save(booking)
inbound.booking = booking
}
inbound.status = InboundEmailStatus.CANCELLED
inbound.processedAt = OffsetDateTime.now()
inboundEmailRepo.save(inbound)
return
}
val sourceBookingId = otaBookingId ?: fallbackKey
if (bookingRepo.existsByPropertyIdAndSourceBookingId(property.id!!, sourceBookingId)) {
inbound.status = InboundEmailStatus.SKIPPED
inbound.processedAt = OffsetDateTime.now()
inboundEmailRepo.save(inbound)
return
}
val guest = resolveGuest(property, extracted)
val emailUrl = "${publicBaseUrl}/properties/${property.id}/inbound-emails/${inbound.id}/file"
val booking = createBooking(property, guest, extracted, sourceBookingId, emailUrl)
inbound.booking = booking
inbound.status = InboundEmailStatus.CREATED
inbound.processedAt = OffsetDateTime.now()
inboundEmailRepo.save(inbound)
}
private fun buildMessageId(message: Message): String? {
val header = message.getHeader("Message-ID")?.firstOrNull()
if (!header.isNullOrBlank()) return header
@@ -253,10 +272,16 @@ class EmailIngestionService(
}
}
private fun matchProperty(subject: String?, body: String): Property? {
private fun matchProperty(subject: String?, body: String, recipients: List<String>): Property? {
val haystack = "${subject ?: ""}\n$body".lowercase()
val properties = propertyRepo.findAll()
val matches = properties.filter { property ->
if (recipients.isNotEmpty()) {
val propertyEmails = property.emailAddresses.map { it.lowercase() }.toSet()
if (propertyEmails.isNotEmpty() && recipients.any { it.lowercase() in propertyEmails }) {
return@filter true
}
}
val aliases = mutableSetOf<String>()
aliases.add(property.name)
aliases.add(property.code)
@@ -267,6 +292,15 @@ class EmailIngestionService(
return if (matches.size == 1) matches.first() else null
}
private fun extractRecipients(message: Message): List<String> {
val list = mutableListOf<String>()
val to = message.getRecipients(Message.RecipientType.TO)
val cc = message.getRecipients(Message.RecipientType.CC)
(to ?: emptyArray()).forEach { list.add(it.toString()) }
(cc ?: emptyArray()).forEach { list.add(it.toString()) }
return list
}
private fun extractText(message: Message): String {
return try {
val content = message.content
@@ -280,6 +314,16 @@ class EmailIngestionService(
}
}
private fun extractRawMessage(message: Message): ByteArray? {
return try {
val out = java.io.ByteArrayOutputStream()
message.writeTo(out)
out.toByteArray()
} catch (_: Exception) {
null
}
}
private fun extractFromMultipart(multipart: Multipart): String {
var text: String? = null
var html: String? = null

View File

@@ -1,2 +1,3 @@
spring.datasource.url=jdbc:postgresql://localhost:5432/trisolaris
ai.llama.baseUrl=http://localhost:8089/v1/chat/completions
logging.level.com.android.trisolarisserver.controller.Auth=INFO

View File

@@ -10,6 +10,9 @@ storage.documents.publicBaseUrl=https://api.hoteltrisolaris.in
storage.documents.tokenSecret=change-me
storage.documents.tokenTtlSeconds=300
storage.emails.root=/home/androidlover5842/docs/emails
storage.emails.publicBaseUrl=https://api.hoteltrisolaris.in
storage.rooms.root=/home/androidlover5842/docs/rooms
storage.rooms.publicBaseUrl=https://api.hoteltrisolaris.in
mail.imap.host=localhost
mail.imap.port=993
mail.imap.protocol=imaps
@@ -17,3 +20,4 @@ mail.imap.username=
mail.imap.password=
mail.imap.pollMs=60000
mail.imap.enabled=false
server.port=18921