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

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

View File

@@ -1,5 +1,6 @@
package com.android.trisolarisserver.component package com.android.trisolarisserver.component
import com.android.trisolarisserver.repo.AppUserRepo
import com.android.trisolarisserver.repo.PropertyUserRepo import com.android.trisolarisserver.repo.PropertyUserRepo
import com.android.trisolarisserver.models.property.Role import com.android.trisolarisserver.models.property.Role
import org.springframework.security.access.AccessDeniedException import org.springframework.security.access.AccessDeniedException
@@ -8,15 +9,30 @@ import java.util.UUID
@Component @Component
class PropertyAccess( class PropertyAccess(
private val repo: PropertyUserRepo private val repo: PropertyUserRepo,
private val appUserRepo: AppUserRepo
) { ) {
fun requireMember(propertyId: UUID, userId: UUID) { fun requireMember(propertyId: UUID, userId: UUID) {
if (!repo.existsByIdPropertyIdAndIdUserId(propertyId, userId)) val user = appUserRepo.findById(userId).orElse(null)
throw AccessDeniedException("No access to property") 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) { fun requireAnyRole(propertyId: UUID, userId: UUID, vararg roles: Role) {
if (!repo.hasAnyRole(propertyId, userId, roles.toSet())) val user = appUserRepo.findById(userId).orElse(null)
throw AccessDeniedException("Missing role") 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 package com.android.trisolarisserver.config
import org.springframework.boot.web.client.RestTemplateBuilder
import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Configuration
import org.springframework.http.client.SimpleClientHttpRequestFactory
import org.springframework.web.client.RestTemplate import org.springframework.web.client.RestTemplate
import java.time.Duration
@Configuration @Configuration
class HttpConfig { class HttpConfig {
@Bean @Bean
fun restTemplate(builder: RestTemplateBuilder): RestTemplate { fun restTemplate(): RestTemplate {
return builder val factory = SimpleClientHttpRequestFactory().apply {
.setConnectTimeout(Duration.ofSeconds(10)) setConnectTimeout(10_000)
.setReadTimeout(Duration.ofMinutes(5)) setReadTimeout(5 * 60 * 1_000)
.build() }
return RestTemplate(factory)
} }
} }

View File

@@ -5,13 +5,20 @@ import com.android.trisolarisserver.controller.dto.UserResponse
import com.android.trisolarisserver.repo.AppUserRepo import com.android.trisolarisserver.repo.AppUserRepo
import com.android.trisolarisserver.repo.PropertyUserRepo import com.android.trisolarisserver.repo.PropertyUserRepo
import com.android.trisolarisserver.security.MyPrincipal 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.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PostMapping 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.RequestMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import org.springframework.web.server.ResponseStatusException import org.springframework.web.server.ResponseStatusException
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import java.util.UUID
@RestController @RestController
@RequestMapping("/auth") @RequestMapping("/auth")
@@ -19,21 +26,49 @@ class Auth(
private val appUserRepo: AppUserRepo, private val appUserRepo: AppUserRepo,
private val propertyUserRepo: PropertyUserRepo private val propertyUserRepo: PropertyUserRepo
) { ) {
private val logger = LoggerFactory.getLogger(Auth::class.java)
@PostMapping("/verify") @PostMapping("/verify")
fun verify(@AuthenticationPrincipal principal: MyPrincipal?): AuthResponse { fun verify(
return buildAuthResponse(principal) @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") @GetMapping("/me")
fun me(@AuthenticationPrincipal principal: MyPrincipal?): AuthResponse { fun me(
return buildAuthResponse(principal) @AuthenticationPrincipal principal: MyPrincipal?,
request: HttpServletRequest
): ResponseEntity<AuthResponse> {
val resolved = principal?.let { ResolveResult(it) } ?: resolvePrincipalFromHeader(request)
return resolved.toResponseEntity()
} }
private fun buildAuthResponse(principal: MyPrincipal?): AuthResponse { @PutMapping("/me")
if (principal == null) { fun updateMe(
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal") @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 { val user = appUserRepo.findById(principal.userId).orElseThrow {
ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found") ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found")
} }
@@ -44,21 +79,85 @@ class Auth(
roles = it.roles.map { role -> role.name }.toSet() roles = it.roles.map { role -> role.name }.toSet()
) )
} }
val status = when {
user.superAdmin -> "SUPER_ADMIN"
memberships.isEmpty() -> "NO_PROPERTIES"
else -> "OK"
}
return AuthResponse( return AuthResponse(
status = status,
user = UserResponse( user = UserResponse(
id = user.id!!, id = user.id!!,
orgId = user.org.id!!,
firebaseUid = user.firebaseUid, firebaseUid = user.firebaseUid,
phoneE164 = user.phoneE164, phoneE164 = user.phoneE164,
name = user.name, name = user.name,
disabled = user.disabled disabled = user.disabled,
superAdmin = user.superAdmin
), ),
properties = memberships 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( data class AuthResponse(
val user: UserResponse, val status: String,
val properties: List<PropertyUserResponse> 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 { val guest = guestRepo.findById(guestId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Guest not found") ResponseStatusException(HttpStatus.NOT_FOUND, "Guest not found")
} }
if (guest.org.id != property.org.id) { if (guest.property.id != property.id) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Guest not in property org") throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Guest not in property")
} }
val booking = bookingRepo.findById(bookingId).orElseThrow { val booking = bookingRepo.findById(bookingId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found") 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.PropertyUpdateRequest
import com.android.trisolarisserver.controller.dto.PropertyUserResponse import com.android.trisolarisserver.controller.dto.PropertyUserResponse
import com.android.trisolarisserver.controller.dto.PropertyUserRoleRequest 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.AppUserRepo
import com.android.trisolarisserver.repo.OrganizationRepo
import com.android.trisolarisserver.repo.PropertyRepo import com.android.trisolarisserver.repo.PropertyRepo
import com.android.trisolarisserver.repo.PropertyUserRepo import com.android.trisolarisserver.repo.PropertyUserRepo
import com.android.trisolarisserver.models.property.Property 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.PropertyUserId
import com.android.trisolarisserver.models.property.Role import com.android.trisolarisserver.models.property.Role
import com.android.trisolarisserver.security.MyPrincipal import com.android.trisolarisserver.security.MyPrincipal
import com.android.trisolarisserver.models.booking.TransportMode
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.DeleteMapping
@@ -33,69 +32,61 @@ import java.util.UUID
class Properties( class Properties(
private val propertyAccess: PropertyAccess, private val propertyAccess: PropertyAccess,
private val propertyRepo: PropertyRepo, private val propertyRepo: PropertyRepo,
private val orgRepo: OrganizationRepo,
private val propertyUserRepo: PropertyUserRepo, private val propertyUserRepo: PropertyUserRepo,
private val appUserRepo: AppUserRepo private val appUserRepo: AppUserRepo
) { ) {
@PostMapping("/orgs/{orgId}/properties") @PostMapping("/properties")
@ResponseStatus(HttpStatus.CREATED) @ResponseStatus(HttpStatus.CREATED)
fun createProperty( fun createProperty(
@PathVariable orgId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?, @AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: PropertyCreateRequest @RequestBody request: PropertyCreateRequest
): PropertyResponse { ): PropertyResponse {
val user = requireUser(principal) val user = requireUser(principal)
if (user.org.id != orgId) { if (propertyRepo.existsByCode(request.code)) {
throw ResponseStatusException(HttpStatus.FORBIDDEN, "No access to org") throw ResponseStatusException(HttpStatus.CONFLICT, "Property code already exists")
}
requireOrgRole(orgId, user.id!!, Role.ADMIN)
if (propertyRepo.existsByOrgIdAndCode(orgId, request.code)) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Property code already exists for org")
} }
val org = orgRepo.findById(orgId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Org not found")
}
val property = Property( val property = Property(
org = org,
code = request.code, code = request.code,
name = request.name, name = request.name,
addressText = request.addressText, addressText = request.addressText,
timezone = request.timezone ?: "Asia/Kolkata", timezone = request.timezone ?: "Asia/Kolkata",
currency = request.currency ?: "INR", currency = request.currency ?: "INR",
active = request.active ?: true, active = request.active ?: true,
otaAliases = request.otaAliases?.toMutableSet() ?: mutableSetOf() otaAliases = request.otaAliases?.toMutableSet() ?: mutableSetOf(),
emailAddresses = request.emailAddresses?.toMutableSet() ?: mutableSetOf(),
allowedTransportModes = request.allowedTransportModes?.let { parseTransportModes(it) } ?: mutableSetOf()
) )
val saved = propertyRepo.save(property) val saved = propertyRepo.save(property)
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() return saved.toResponse()
} }
@GetMapping("/orgs/{orgId}/properties") @GetMapping("/properties")
fun listProperties( fun listProperties(
@PathVariable orgId: UUID,
@AuthenticationPrincipal principal: MyPrincipal? @AuthenticationPrincipal principal: MyPrincipal?
): List<PropertyResponse> { ): List<PropertyResponse> {
val user = requireUser(principal) val user = requireUser(principal)
if (user.org.id != orgId) { return if (user.superAdmin) {
throw ResponseStatusException(HttpStatus.FORBIDDEN, "No access to org") 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") @GetMapping("/properties/{propertyId}/users")
@@ -126,8 +117,10 @@ class Properties(
requirePrincipal(principal) requirePrincipal(principal)
propertyAccess.requireMember(propertyId, principal!!.userId) propertyAccess.requireMember(propertyId, principal!!.userId)
val actorUser = appUserRepo.findById(principal.userId).orElse(null)
val actorRoles = propertyUserRepo.findRolesByPropertyAndUser(propertyId, principal.userId) val actorRoles = propertyUserRepo.findRolesByPropertyAndUser(propertyId, principal.userId)
val allowedRoles = when { 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.ADMIN) -> setOf(Role.ADMIN, Role.MANAGER, Role.STAFF, Role.AGENT)
actorRoles.contains(Role.MANAGER) -> setOf(Role.STAFF, Role.AGENT) actorRoles.contains(Role.MANAGER) -> setOf(Role.STAFF, Role.AGENT)
else -> emptySet() else -> emptySet()
@@ -150,9 +143,6 @@ class Properties(
val targetUser = appUserRepo.findById(userId).orElseThrow { val targetUser = appUserRepo.findById(userId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "User not found") 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( val propertyUser = PropertyUser(
id = PropertyUserId(propertyId = propertyId, userId = userId), id = PropertyUserId(propertyId = propertyId, userId = userId),
@@ -198,8 +188,8 @@ class Properties(
val property = propertyRepo.findById(propertyId).orElseThrow { val property = propertyRepo.findById(propertyId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found") ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
} }
if (propertyRepo.existsByOrgIdAndCodeAndIdNot(property.org.id!!, request.code, propertyId)) { if (propertyRepo.existsByCodeAndIdNot(request.code, propertyId)) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Property code already exists for org") throw ResponseStatusException(HttpStatus.CONFLICT, "Property code already exists")
} }
property.code = request.code property.code = request.code
@@ -211,6 +201,12 @@ class Properties(
if (request.otaAliases != null) { if (request.otaAliases != null) {
property.otaAliases = request.otaAliases.toMutableSet() property.otaAliases = request.otaAliases.toMutableSet()
} }
if (request.emailAddresses != null) {
property.emailAddresses = request.emailAddresses.toMutableSet()
}
if (request.allowedTransportModes != null) {
property.allowedTransportModes = parseTransportModes(request.allowedTransportModes)
}
return propertyRepo.save(property).toResponse() return propertyRepo.save(property).toResponse()
} }
@@ -230,38 +226,27 @@ class Properties(
} }
} }
private fun requireOrgRole(orgId: UUID, userId: UUID, vararg roles: Role) { private fun parseTransportModes(modes: Set<String>): MutableSet<TransportMode> {
if (!propertyUserRepo.hasAnyRoleInOrg(orgId, userId, roles.toSet())) { return try {
throw ResponseStatusException(HttpStatus.FORBIDDEN, "Missing role") modes.map { TransportMode.valueOf(it) }.toMutableSet()
} catch (_: IllegalArgumentException) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown transport mode")
} }
} }
} }
private fun Property.toResponse(): PropertyResponse { private fun Property.toResponse(): PropertyResponse {
val id = id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Property id missing") 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( return PropertyResponse(
id = id, id = id,
orgId = orgId,
code = code, code = code,
name = name, name = name,
addressText = addressText, addressText = addressText,
timezone = timezone, timezone = timezone,
currency = currency, currency = currency,
active = active, active = active,
otaAliases = otaAliases.toSet() otaAliases = otaAliases.toSet(),
) emailAddresses = emailAddresses.toSet(),
} allowedTransportModes = allowedTransportModes.map { it.name }.toSet()
private fun com.android.trisolarisserver.models.property.AppUser.toUserResponse(): UserResponse {
val id = this.id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "User id missing")
val orgId = this.org.id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Org id missing")
return UserResponse(
id = id,
orgId = orgId,
firebaseUid = firebaseUid,
phoneE164 = phoneE164,
name = name,
disabled = disabled
) )
} }

View File

@@ -0,0 +1,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.RoomTypeResponse
import com.android.trisolarisserver.controller.dto.RoomTypeUpsertRequest import com.android.trisolarisserver.controller.dto.RoomTypeUpsertRequest
import com.android.trisolarisserver.repo.PropertyRepo import com.android.trisolarisserver.repo.PropertyRepo
import com.android.trisolarisserver.repo.RoomAmenityRepo
import com.android.trisolarisserver.repo.RoomRepo import com.android.trisolarisserver.repo.RoomRepo
import com.android.trisolarisserver.repo.RoomTypeRepo import com.android.trisolarisserver.repo.RoomTypeRepo
import com.android.trisolarisserver.models.property.Role 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.models.room.RoomType
import com.android.trisolarisserver.security.MyPrincipal import com.android.trisolarisserver.security.MyPrincipal
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
@@ -28,6 +30,7 @@ import java.util.UUID
class RoomTypes( class RoomTypes(
private val propertyAccess: PropertyAccess, private val propertyAccess: PropertyAccess,
private val roomTypeRepo: RoomTypeRepo, private val roomTypeRepo: RoomTypeRepo,
private val roomAmenityRepo: RoomAmenityRepo,
private val roomRepo: RoomRepo, private val roomRepo: RoomRepo,
private val propertyRepo: PropertyRepo private val propertyRepo: PropertyRepo
) { ) {
@@ -66,11 +69,27 @@ class RoomTypes(
name = request.name, name = request.name,
baseOccupancy = request.baseOccupancy ?: 2, baseOccupancy = request.baseOccupancy ?: 2,
maxOccupancy = request.maxOccupancy ?: 3, maxOccupancy = request.maxOccupancy ?: 3,
sqFeet = request.sqFeet,
bathroomSqFeet = request.bathroomSqFeet,
otaAliases = request.otaAliases?.toMutableSet() ?: mutableSetOf() otaAliases = request.otaAliases?.toMutableSet() ?: mutableSetOf()
) )
if (request.amenityIds != null) {
roomType.amenities = resolveAmenities(request.amenityIds)
}
return roomTypeRepo.save(roomType).toResponse() 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}") @PutMapping("/{roomTypeId}")
fun updateRoomType( fun updateRoomType(
@PathVariable propertyId: UUID, @PathVariable propertyId: UUID,
@@ -93,9 +112,14 @@ class RoomTypes(
roomType.name = request.name roomType.name = request.name
roomType.baseOccupancy = request.baseOccupancy ?: roomType.baseOccupancy roomType.baseOccupancy = request.baseOccupancy ?: roomType.baseOccupancy
roomType.maxOccupancy = request.maxOccupancy ?: roomType.maxOccupancy roomType.maxOccupancy = request.maxOccupancy ?: roomType.maxOccupancy
roomType.sqFeet = request.sqFeet ?: roomType.sqFeet
roomType.bathroomSqFeet = request.bathroomSqFeet ?: roomType.bathroomSqFeet
if (request.otaAliases != null) { if (request.otaAliases != null) {
roomType.otaAliases = request.otaAliases.toMutableSet() roomType.otaAliases = request.otaAliases.toMutableSet()
} }
if (request.amenityIds != null) {
roomType.amenities = resolveAmenities(request.amenityIds)
}
return roomTypeRepo.save(roomType).toResponse() return roomTypeRepo.save(roomType).toResponse()
} }
@@ -136,6 +160,19 @@ private fun RoomType.toResponse(): RoomTypeResponse {
name = name, name = name,
baseOccupancy = baseOccupancy, baseOccupancy = baseOccupancy,
maxOccupancy = maxOccupancy, 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 package com.android.trisolarisserver.controller
import com.android.trisolarisserver.component.PropertyAccess import com.android.trisolarisserver.component.PropertyAccess
import com.android.trisolarisserver.component.RoomBoardEvents
import com.android.trisolarisserver.controller.dto.RoomAvailabilityRangeResponse
import com.android.trisolarisserver.controller.dto.RoomAvailabilityResponse import com.android.trisolarisserver.controller.dto.RoomAvailabilityResponse
import com.android.trisolarisserver.controller.dto.RoomBoardResponse import com.android.trisolarisserver.controller.dto.RoomBoardResponse
import com.android.trisolarisserver.controller.dto.RoomBoardStatus import com.android.trisolarisserver.controller.dto.RoomBoardStatus
@@ -25,6 +27,9 @@ import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import org.springframework.web.server.ResponseStatusException import org.springframework.web.server.ResponseStatusException
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter
import java.time.LocalDate
import java.time.ZoneId
import java.util.UUID import java.util.UUID
@RestController @RestController
@@ -35,7 +40,8 @@ class Rooms(
private val roomStayRepo: RoomStayRepo, private val roomStayRepo: RoomStayRepo,
private val propertyRepo: PropertyRepo, private val propertyRepo: PropertyRepo,
private val roomTypeRepo: RoomTypeRepo, private val roomTypeRepo: RoomTypeRepo,
private val propertyUserRepo: PropertyUserRepo private val propertyUserRepo: PropertyUserRepo,
private val roomBoardEvents: RoomBoardEvents
) { ) {
@GetMapping @GetMapping
@@ -85,6 +91,16 @@ class Rooms(
return if (isAgentOnly(roles)) mapped.filter { it.status == RoomBoardStatus.FREE } else mapped return if (isAgentOnly(roles)) mapped.filter { it.status == RoomBoardStatus.FREE } else mapped
} }
@GetMapping("/board/stream")
fun roomBoardStream(
@PathVariable propertyId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
): SseEmitter {
requirePrincipal(principal)
propertyAccess.requireMember(propertyId, principal!!.userId)
return roomBoardEvents.subscribe(propertyId)
}
@GetMapping("/availability") @GetMapping("/availability")
fun roomAvailability( fun roomAvailability(
@PathVariable propertyId: UUID, @PathVariable propertyId: UUID,
@@ -106,6 +122,43 @@ class Rooms(
}.sortedBy { it.roomTypeName } }.sortedBy { it.roomTypeName }
} }
@GetMapping("/availability-range")
fun roomAvailabilityRange(
@PathVariable propertyId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@org.springframework.web.bind.annotation.RequestParam("from") from: String,
@org.springframework.web.bind.annotation.RequestParam("to") to: String
): List<RoomAvailabilityRangeResponse> {
requirePrincipal(principal)
propertyAccess.requireMember(propertyId, principal!!.userId)
val property = propertyRepo.findById(propertyId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
}
val fromDate = parseDate(from)
val toDate = parseDate(to)
if (!toDate.isAfter(fromDate)) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid date range")
}
val zone = ZoneId.of(property.timezone)
val fromAt = fromDate.atStartOfDay(zone).toOffsetDateTime()
val toAt = toDate.atStartOfDay(zone).toOffsetDateTime()
val rooms = roomRepo.findByPropertyIdOrderByRoomNumber(propertyId)
val occupiedRoomIds = roomStayRepo.findOccupiedRoomIdsBetween(propertyId, fromAt, toAt).toHashSet()
val freeRooms = rooms.filter { it.active && !it.maintenance && !occupiedRoomIds.contains(it.id) }
val grouped = freeRooms.groupBy { it.roomType.name }
return grouped.entries.map { (typeName, roomList) ->
RoomAvailabilityRangeResponse(
roomTypeName = typeName,
freeRoomNumbers = roomList.map { it.roomNumber },
freeCount = roomList.size
)
}.sortedBy { it.roomTypeName }
}
@PostMapping @PostMapping
@ResponseStatus(HttpStatus.CREATED) @ResponseStatus(HttpStatus.CREATED)
fun createRoom( fun createRoom(
@@ -123,8 +176,7 @@ class Rooms(
val property = propertyRepo.findById(propertyId).orElseThrow { val property = propertyRepo.findById(propertyId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found") ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
} }
val roomType = roomTypeRepo.findByIdAndPropertyId(request.roomTypeId, propertyId) val roomType = resolveRoomType(propertyId, request)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room type not found")
val room = Room( val room = Room(
property = property, property = property,
@@ -137,7 +189,19 @@ class Rooms(
notes = request.notes 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}") @PutMapping("/{roomId}")
@@ -157,8 +221,7 @@ class Rooms(
throw ResponseStatusException(HttpStatus.CONFLICT, "Room number already exists for property") throw ResponseStatusException(HttpStatus.CONFLICT, "Room number already exists for property")
} }
val roomType = roomTypeRepo.findByIdAndPropertyId(request.roomTypeId, propertyId) val roomType = resolveRoomType(propertyId, request)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room type not found")
room.roomNumber = request.roomNumber room.roomNumber = request.roomNumber
room.floor = request.floor room.floor = request.floor
@@ -168,7 +231,19 @@ class Rooms(
room.maintenance = request.maintenance room.maintenance = request.maintenance
room.notes = request.notes room.notes = request.notes
return roomRepo.save(room).toRoomResponse() val saved = roomRepo.save(room)
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?) { private fun requirePrincipal(principal: MyPrincipal?) {
@@ -182,16 +257,31 @@ class Rooms(
val privileged = setOf(Role.ADMIN, Role.MANAGER, Role.STAFF, Role.HOUSEKEEPING, Role.FINANCE) val privileged = setOf(Role.ADMIN, Role.MANAGER, Role.STAFF, Role.HOUSEKEEPING, Role.FINANCE)
return roles.none { it in privileged } return roles.none { it in privileged }
} }
private fun parseDate(value: String): LocalDate {
return try {
LocalDate.parse(value.trim())
} catch (_: Exception) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid date format")
}
}
private fun 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 { private fun Room.toRoomResponse(): RoomResponse {
val roomId = id ?: throw IllegalStateException("Room id is null") val roomId = id ?: throw IllegalStateException("Room id is null")
val roomTypeId = roomType.id ?: throw IllegalStateException("Room type id is null")
return RoomResponse( return RoomResponse(
id = roomId, id = roomId,
roomNumber = roomNumber, roomNumber = roomNumber,
floor = floor, floor = floor,
roomTypeId = roomTypeId,
roomTypeName = roomType.name, roomTypeName = roomType.name,
hasNfc = hasNfc, hasNfc = hasNfc,
active = active, 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 import java.util.UUID
data class OrgCreateRequest(
val name: String
)
data class OrgResponse(
val id: UUID,
val name: String
)
data class PropertyCreateRequest( data class PropertyCreateRequest(
val code: String, val code: String,
val name: String, val name: String,
@@ -18,7 +9,9 @@ data class PropertyCreateRequest(
val timezone: String? = null, val timezone: String? = null,
val currency: String? = null, val currency: String? = null,
val active: Boolean? = null, val active: Boolean? = null,
val otaAliases: Set<String>? = null val otaAliases: Set<String>? = null,
val emailAddresses: Set<String>? = null,
val allowedTransportModes: Set<String>? = null
) )
data class PropertyUpdateRequest( data class PropertyUpdateRequest(
@@ -28,28 +21,50 @@ data class PropertyUpdateRequest(
val timezone: String? = null, val timezone: String? = null,
val currency: String? = null, val currency: String? = null,
val active: Boolean? = null, val active: Boolean? = null,
val otaAliases: Set<String>? = null val otaAliases: Set<String>? = null,
val emailAddresses: Set<String>? = null,
val allowedTransportModes: Set<String>? = null
) )
data class PropertyResponse( data class PropertyResponse(
val id: UUID, val id: UUID,
val orgId: UUID,
val code: String, val code: String,
val name: String, val name: String,
val addressText: String?, val addressText: String?,
val timezone: String, val timezone: String,
val currency: String, val currency: String,
val active: Boolean, val active: Boolean,
val otaAliases: Set<String> val otaAliases: Set<String>,
val emailAddresses: Set<String>,
val allowedTransportModes: Set<String>
)
data class GuestResponse(
val id: UUID,
val name: String?,
val phoneE164: String?,
val nationality: String?,
val addressText: String?,
val vehicleNumbers: Set<String>,
val averageScore: Double?
)
data class GuestVehicleRequest(
val vehicleNumber: String
)
data class TransportModeStatusResponse(
val mode: String,
val enabled: Boolean
) )
data class UserResponse( data class UserResponse(
val id: UUID, val id: UUID,
val orgId: UUID,
val firebaseUid: String?, val firebaseUid: String?,
val phoneE164: String?, val phoneE164: String?,
val name: String?, val name: String?,
val disabled: Boolean val disabled: Boolean,
val superAdmin: Boolean
) )
data class PropertyUserRoleRequest( data class PropertyUserRoleRequest(

View File

@@ -6,7 +6,6 @@ data class RoomResponse(
val id: UUID, val id: UUID,
val roomNumber: Int, val roomNumber: Int,
val floor: Int?, val floor: Int?,
val roomTypeId: UUID,
val roomTypeName: String, val roomTypeName: String,
val hasNfc: Boolean, val hasNfc: Boolean,
val active: Boolean, val active: Boolean,
@@ -25,6 +24,23 @@ data class RoomAvailabilityResponse(
val freeRoomNumbers: List<Int> val freeRoomNumbers: List<Int>
) )
data class RoomAvailabilityRangeResponse(
val roomTypeName: String,
val freeRoomNumbers: List<Int>,
val freeCount: Int
)
data class RoomImageResponse(
val id: UUID,
val propertyId: UUID,
val roomId: UUID,
val url: String,
val thumbnailUrl: String,
val contentType: String,
val sizeBytes: Long,
val createdAt: String
)
enum class RoomBoardStatus { enum class RoomBoardStatus {
FREE, FREE,
OCCUPIED, OCCUPIED,
@@ -35,7 +51,7 @@ enum class RoomBoardStatus {
data class RoomUpsertRequest( data class RoomUpsertRequest(
val roomNumber: Int, val roomNumber: Int,
val floor: Int?, val floor: Int?,
val roomTypeId: UUID, val roomTypeCode: String,
val hasNfc: Boolean, val hasNfc: Boolean,
val active: Boolean, val active: Boolean,
val maintenance: 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 name: String,
val baseOccupancy: Int? = null, val baseOccupancy: Int? = null,
val maxOccupancy: 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( data class RoomTypeResponse(
@@ -17,5 +20,21 @@ data class RoomTypeResponse(
val name: String, val name: String,
val baseOccupancy: Int, val baseOccupancy: Int,
val maxOccupancy: 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 import java.util.UUID
interface GuestRepo : JpaRepository<Guest, 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> { interface InboundEmailRepo : JpaRepository<InboundEmail, UUID> {
fun findByMessageId(messageId: String): InboundEmail? fun findByMessageId(messageId: String): InboundEmail?
fun findByPropertyIdAndOtaBookingId(propertyId: UUID, otaBookingId: String): InboundEmail? fun findByPropertyIdAndOtaBookingId(propertyId: UUID, otaBookingId: String): InboundEmail?
fun findByIdAndPropertyId(id: UUID, propertyId: UUID): InboundEmail?
fun existsByMessageId(messageId: String): Boolean fun existsByMessageId(messageId: String): Boolean
fun existsByPropertyIdAndOtaBookingId(propertyId: UUID, otaBookingId: String): Boolean fun existsByPropertyIdAndOtaBookingId(propertyId: UUID, otaBookingId: String): Boolean
} }

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,11 @@
package com.android.trisolarisserver.models.property 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.time.OffsetDateTime
import java.util.UUID import java.util.UUID
@@ -15,10 +20,6 @@ class AppUser(
@Column(columnDefinition = "uuid") @Column(columnDefinition = "uuid")
val id: UUID? = null, val id: UUID? = null,
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "org_id", nullable = false)
var org: Organization,
@Column(name = "firebase_uid") @Column(name = "firebase_uid")
var firebaseUid: String? = null, // optional if using firebase var firebaseUid: String? = null, // optional if using firebase
@@ -27,6 +28,9 @@ class AppUser(
var name: String? = null, var name: String? = null,
@Column(name = "is_super_admin", nullable = false)
var superAdmin: Boolean = false,
@Column(name = "is_disabled", nullable = false) @Column(name = "is_disabled", nullable = false)
var disabled: Boolean = 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 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.time.OffsetDateTime
import java.util.UUID import java.util.UUID
@Entity @Entity
@Table( @Table(
name = "property", name = "property",
uniqueConstraints = [UniqueConstraint(columnNames = ["org_id", "code"])] uniqueConstraints = [UniqueConstraint(columnNames = ["code"])]
) )
class Property( class Property(
@Id @Id
@@ -15,10 +26,6 @@ class Property(
@Column(columnDefinition = "uuid") @Column(columnDefinition = "uuid")
val id: UUID? = null, val id: UUID? = null,
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "org_id", nullable = false)
var org: Organization,
@Column(nullable = false) @Column(nullable = false)
var code: String, // "TRI-VNS" var code: String, // "TRI-VNS"
@@ -37,6 +44,14 @@ class Property(
@Column(name = "address_text") @Column(name = "address_text")
var addressText: String? = null, var addressText: String? = null,
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(
name = "property_email_address",
joinColumns = [JoinColumn(name = "property_id")]
)
@Column(name = "email", nullable = false)
var emailAddresses: MutableSet<String> = mutableSetOf(),
@ElementCollection(fetch = FetchType.EAGER) @ElementCollection(fetch = FetchType.EAGER)
@CollectionTable( @CollectionTable(
name = "property_email_alias", name = "property_email_alias",
@@ -45,6 +60,16 @@ class Property(
@Column(name = "alias", nullable = false) @Column(name = "alias", nullable = false)
var otaAliases: MutableSet<String> = mutableSetOf(), var otaAliases: MutableSet<String> = mutableSetOf(),
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(
name = "property_transport_mode",
joinColumns = [JoinColumn(name = "property_id")]
)
@Column(name = "mode", nullable = false)
@Enumerated(EnumType.STRING)
var allowedTransportModes: MutableSet<com.android.trisolarisserver.models.booking.TransportMode> =
mutableSetOf(),
@Column(name = "created_at", nullable = false, columnDefinition = "timestamptz") @Column(name = "created_at", nullable = false, columnDefinition = "timestamptz")
val createdAt: OffsetDateTime = OffsetDateTime.now() val createdAt: OffsetDateTime = OffsetDateTime.now()
) )

View File

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

View File

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

View File

@@ -0,0 +1,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) @Column(name = "max_occupancy", nullable = false)
var maxOccupancy: Int = 3, 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) @ElementCollection(fetch = FetchType.EAGER)
@CollectionTable( @CollectionTable(
name = "room_type_alias", name = "room_type_alias",
@@ -40,6 +46,14 @@ class RoomType(
@Column(name = "alias", nullable = false) @Column(name = "alias", nullable = false)
var otaAliases: MutableSet<String> = mutableSetOf(), 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") @Column(name = "created_at", nullable = false, columnDefinition = "timestamptz")
val createdAt: OffsetDateTime = OffsetDateTime.now() val createdAt: OffsetDateTime = OffsetDateTime.now()
) )

View File

@@ -7,5 +7,4 @@ import java.util.UUID
interface AppUserRepo : JpaRepository<AppUser, UUID> { interface AppUserRepo : JpaRepository<AppUser, UUID> {
fun findByFirebaseUid(firebaseUid: String): AppUser? fun findByFirebaseUid(firebaseUid: String): AppUser?
fun existsByFirebaseUid(firebaseUid: String): Boolean 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 import java.util.UUID
interface PropertyRepo : JpaRepository<Property, UUID> { interface PropertyRepo : JpaRepository<Property, UUID> {
fun existsByOrgIdAndCode(orgId: UUID, code: String): Boolean fun existsByCode(code: String): Boolean
fun existsByOrgIdAndCodeAndIdNot(orgId: UUID, code: String, id: UUID): Boolean fun existsByCodeAndIdNot(code: String, id: UUID): Boolean
} }

View File

@@ -25,16 +25,6 @@ interface PropertyUserRepo : JpaRepository<PropertyUser, PropertyUserId> {
@Param("userId") userId: UUID @Param("userId") userId: UUID
): Set<Role> ): 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(""" @Query("""
select case when count(pu) > 0 then true else false end 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> @Param("roles") roles: Set<Role>
): Boolean ): 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> { interface RoomRepo : JpaRepository<Room, UUID> {
@EntityGraph(attributePaths = ["roomType"]) @EntityGraph(attributePaths = ["roomType", "roomType.otaAliases", "roomType.amenities"])
fun findByPropertyIdOrderByRoomNumber(propertyId: UUID): List<Room> fun findByPropertyIdOrderByRoomNumber(propertyId: UUID): List<Room>
@EntityGraph(attributePaths = ["roomType", "roomType.otaAliases", "roomType.amenities"])
fun findByIdAndPropertyId(id: UUID, propertyId: UUID): Room? fun findByIdAndPropertyId(id: UUID, propertyId: UUID): Room?
fun existsByPropertyIdAndRoomNumber(propertyId: UUID, roomNumber: Int): Boolean 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 and rs.toAt is null
""") """)
fun findOccupiedRoomIds(@Param("propertyId") propertyId: UUID): List<UUID> fun findOccupiedRoomIds(@Param("propertyId") propertyId: UUID): List<UUID>
@Query("""
select distinct rs.room.id
from RoomStay rs
where rs.property.id = :propertyId
and rs.fromAt < :toAt
and (rs.toAt is null or rs.toAt > :fromAt)
""")
fun findOccupiedRoomIdsBetween(
@Param("propertyId") propertyId: UUID,
@Param("fromAt") fromAt: java.time.OffsetDateTime,
@Param("toAt") toAt: java.time.OffsetDateTime
): List<UUID>
@Query("""
select rs
from RoomStay rs
where rs.booking.id = :bookingId
and rs.toAt is null
""")
fun findActiveByBookingId(@Param("bookingId") bookingId: UUID): List<RoomStay>
@Query("""
select rs.room.id
from RoomStay rs
where rs.property.id = :propertyId
and rs.room.id in :roomIds
and rs.toAt is null
""")
fun findActiveRoomIds(
@Param("propertyId") propertyId: UUID,
@Param("roomIds") roomIds: List<UUID>
): List<UUID>
@Query("""
select case when count(rs) > 0 then true else false end
from RoomStay rs
where rs.property.id = :propertyId
and rs.room.id = :roomId
and rs.fromAt < :toAt
and (rs.toAt is null or rs.toAt > :fromAt)
""")
fun existsOverlap(
@Param("propertyId") propertyId: UUID,
@Param("roomId") roomId: UUID,
@Param("fromAt") fromAt: java.time.OffsetDateTime,
@Param("toAt") toAt: java.time.OffsetDateTime
): Boolean
@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 import java.util.UUID
interface RoomTypeRepo : JpaRepository<RoomType, UUID> { interface RoomTypeRepo : JpaRepository<RoomType, UUID> {
@EntityGraph(attributePaths = ["property", "otaAliases", "amenities"])
fun findByIdAndPropertyId(id: UUID, propertyId: UUID): RoomType? 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 findByPropertyIdOrderByCode(propertyId: UUID): List<RoomType>
fun existsByPropertyIdAndCode(propertyId: UUID, code: String): Boolean fun existsByPropertyIdAndCode(propertyId: UUID, code: String): Boolean
fun existsByPropertyIdAndCodeAndIdNot(propertyId: UUID, code: String, id: UUID): 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.FilterChain
import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse import jakarta.servlet.http.HttpServletResponse
import org.slf4j.LoggerFactory
import org.springframework.http.HttpHeaders import org.springframework.http.HttpHeaders
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.context.SecurityContextHolder import org.springframework.security.core.context.SecurityContextHolder
@@ -17,6 +18,12 @@ import org.springframework.http.HttpStatus
class FirebaseAuthFilter( class FirebaseAuthFilter(
private val appUserRepo: AppUserRepo private val appUserRepo: AppUserRepo
) : OncePerRequestFilter() { ) : 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( override fun doFilterInternal(
request: HttpServletRequest, request: HttpServletRequest,
@@ -25,6 +32,7 @@ class FirebaseAuthFilter(
) { ) {
val header = request.getHeader(HttpHeaders.AUTHORIZATION) val header = request.getHeader(HttpHeaders.AUTHORIZATION)
if (header.isNullOrBlank() || !header.startsWith("Bearer ")) { if (header.isNullOrBlank() || !header.startsWith("Bearer ")) {
logger.debug("Auth missing/invalid header for {}", request.requestURI)
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Missing Authorization token") response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Missing Authorization token")
return return
} }
@@ -34,6 +42,7 @@ class FirebaseAuthFilter(
val firebaseUid = decoded.uid val firebaseUid = decoded.uid
val user = appUserRepo.findByFirebaseUid(firebaseUid) val user = appUserRepo.findByFirebaseUid(firebaseUid)
?: throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found") ?: throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found")
logger.debug("Auth verified uid={}, userId={}", firebaseUid, user.id)
val principal = MyPrincipal( val principal = MyPrincipal(
userId = user.id ?: throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "User id missing"), userId = user.id ?: throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "User id missing"),
@@ -43,6 +52,7 @@ class FirebaseAuthFilter(
SecurityContextHolder.getContext().authentication = auth SecurityContextHolder.getContext().authentication = auth
filterChain.doFilter(request, response) filterChain.doFilter(request, response)
} catch (ex: Exception) { } catch (ex: Exception) {
logger.debug("Auth failed for {}: {}", request.requestURI, ex.message)
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid token") 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.Bean
import org.springframework.context.annotation.Configuration 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.annotation.web.builders.HttpSecurity
import org.springframework.security.config.http.SessionCreationPolicy import org.springframework.security.config.http.SessionCreationPolicy
import org.springframework.security.web.SecurityFilterChain import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter 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( class SecurityConfig(
private val firebaseAuthFilter: FirebaseAuthFilter private val firebaseAuthFilter: FirebaseAuthFilter,
private val objectMapper: ObjectMapper
) { ) {
@Bean @Bean
fun filterChain(http: HttpSecurity): SecurityFilterChain { fun filterChain(http: HttpSecurity): SecurityFilterChain {
http http
.csrf { it.disable() } .csrf { it.disable() }
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } .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) .addFilterBefore(firebaseAuthFilter, UsernamePasswordAuthenticationFilter::class.java)
return http.build() 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}") @Value("\${mail.imap.protocol:imaps}")
private val protocol: String private val protocol: String
) { ) {
@Value("\${storage.emails.publicBaseUrl}")
private val publicBaseUrl: String = ""
private val running = AtomicBoolean(false) private val running = AtomicBoolean(false)
@Value("\${mail.imap.enabled:false}") @Value("\${mail.imap.enabled:false}")
private val enabled: Boolean = false private val enabled: Boolean = false
@@ -86,9 +88,10 @@ class EmailIngestionService(
val subject = message.subject val subject = message.subject
val from = message.from?.firstOrNull()?.toString() val from = message.from?.firstOrNull()?.toString()
val recipients = extractRecipients(message)
val receivedAt = message.receivedDate?.toInstant()?.atOffset(OffsetDateTime.now().offset) val receivedAt = message.receivedDate?.toInstant()?.atOffset(OffsetDateTime.now().offset)
val body = extractText(message) val body = extractText(message)
val property = matchProperty(subject, body) val property = matchProperty(subject, body, recipients)
val inbound = InboundEmail( val inbound = InboundEmail(
property = property, property = property,
@@ -98,6 +101,10 @@ class EmailIngestionService(
receivedAt = receivedAt receivedAt = receivedAt
) )
val rawBytes = extractRawMessage(message)
if (rawBytes != null) {
inbound.rawEmlPath = emailStorage.storeEml(property?.id, messageId, rawBytes)
}
inbound.rawPdfPath = emailStorage.storePdf(property?.id, messageId, subject, body) inbound.rawPdfPath = emailStorage.storePdf(property?.id, messageId, subject, body)
inboundEmailRepo.save(inbound) inboundEmailRepo.save(inbound)
@@ -108,56 +115,7 @@ class EmailIngestionService(
return return
} }
val extracted = extractBookingDetails(body) handleExtracted(property, inbound, body, "email:$messageId")
inbound.extractedData = objectMapper.writeValueAsString(extracted)
val otaBookingId = extracted["otaBookingId"]?.takeIf { !it.contains("NONE", true) }
if (!otaBookingId.isNullOrBlank() &&
inboundEmailRepo.existsByPropertyIdAndOtaBookingId(property.id!!, otaBookingId)
) {
inbound.status = InboundEmailStatus.SKIPPED
inbound.processedAt = OffsetDateTime.now()
inboundEmailRepo.save(inbound)
return
}
inbound.otaBookingId = otaBookingId
inboundEmailRepo.save(inbound)
val isCancel = extracted["isCancel"]?.contains("YES", ignoreCase = true) == true
if (isCancel) {
if (otaBookingId.isNullOrBlank()) {
inbound.status = InboundEmailStatus.SKIPPED
inbound.processedAt = OffsetDateTime.now()
inboundEmailRepo.save(inbound)
return
}
val booking = bookingRepo.findByPropertyIdAndSourceBookingId(property.id!!, otaBookingId)
if (booking != null) {
booking.status = BookingStatus.CANCELLED
bookingRepo.save(booking)
inbound.booking = booking
}
inbound.status = InboundEmailStatus.CANCELLED
inbound.processedAt = OffsetDateTime.now()
inboundEmailRepo.save(inbound)
return
}
val sourceBookingId = otaBookingId ?: "email:$messageId"
if (bookingRepo.existsByPropertyIdAndSourceBookingId(property.id!!, sourceBookingId)) {
inbound.status = InboundEmailStatus.SKIPPED
inbound.processedAt = OffsetDateTime.now()
inboundEmailRepo.save(inbound)
return
}
val guest = resolveGuest(property, extracted)
val booking = createBooking(property, guest, extracted, sourceBookingId)
inbound.booking = booking
inbound.status = InboundEmailStatus.CREATED
inbound.processedAt = OffsetDateTime.now()
inboundEmailRepo.save(inbound)
} }
private fun extractBookingDetails(body: String): Map<String, String> { private fun extractBookingDetails(body: String): Map<String, String> {
@@ -199,11 +157,11 @@ class EmailIngestionService(
private fun resolveGuest(property: Property, extracted: Map<String, String>): Guest { private fun resolveGuest(property: Property, extracted: Map<String, String>): Guest {
val phone = extracted["guestPhone"]?.takeIf { !it.contains("NONE", true) }?.trim() val phone = extracted["guestPhone"]?.takeIf { !it.contains("NONE", true) }?.trim()
if (!phone.isNullOrBlank()) { if (!phone.isNullOrBlank()) {
val existing = guestRepo.findByOrgIdAndPhoneE164(property.org.id!!, phone) val existing = guestRepo.findByPropertyIdAndPhoneE164(property.id!!, phone)
if (existing != null) return existing if (existing != null) return existing
} }
val guest = Guest( val guest = Guest(
org = property.org, property = property,
phoneE164 = phone, phoneE164 = phone,
name = extracted["guestName"]?.takeIf { !it.contains("NONE", true) } name = extracted["guestName"]?.takeIf { !it.contains("NONE", true) }
) )
@@ -214,7 +172,8 @@ class EmailIngestionService(
property: Property, property: Property,
guest: Guest, guest: Guest,
extracted: Map<String, String>, extracted: Map<String, String>,
sourceBookingId: String sourceBookingId: String,
emailAuditPdfUrl: String?
): Booking { ): Booking {
val zone = ZoneId.of(property.timezone) val zone = ZoneId.of(property.timezone)
val checkin = parsedDate(extracted["checkinDate"], zone) val checkin = parsedDate(extracted["checkinDate"], zone)
@@ -226,11 +185,71 @@ class EmailIngestionService(
source = extracted["otaSource"]?.takeIf { !it.contains("NONE", true) } ?: "OTA", source = extracted["otaSource"]?.takeIf { !it.contains("NONE", true) } ?: "OTA",
sourceBookingId = sourceBookingId, sourceBookingId = sourceBookingId,
expectedCheckinAt = checkin, expectedCheckinAt = checkin,
expectedCheckoutAt = checkout expectedCheckoutAt = checkout,
emailAuditPdfUrl = emailAuditPdfUrl
) )
return bookingRepo.save(booking) return bookingRepo.save(booking)
} }
fun ingestManualPdf(property: Property, inbound: InboundEmail, body: String) {
inboundEmailRepo.save(inbound)
handleExtracted(property, inbound, body, "manual:${inbound.id}")
}
private fun handleExtracted(property: Property, inbound: InboundEmail, body: String, fallbackKey: String) {
val extracted = extractBookingDetails(body)
inbound.extractedData = objectMapper.writeValueAsString(extracted)
val otaBookingId = extracted["otaBookingId"]?.takeIf { !it.contains("NONE", true) }
if (!otaBookingId.isNullOrBlank() &&
inboundEmailRepo.existsByPropertyIdAndOtaBookingId(property.id!!, otaBookingId)
) {
inbound.status = InboundEmailStatus.SKIPPED
inbound.processedAt = OffsetDateTime.now()
inboundEmailRepo.save(inbound)
return
}
inbound.otaBookingId = otaBookingId
inboundEmailRepo.save(inbound)
val isCancel = extracted["isCancel"]?.contains("YES", ignoreCase = true) == true
if (isCancel) {
if (otaBookingId.isNullOrBlank()) {
inbound.status = InboundEmailStatus.SKIPPED
inbound.processedAt = OffsetDateTime.now()
inboundEmailRepo.save(inbound)
return
}
val booking = bookingRepo.findByPropertyIdAndSourceBookingId(property.id!!, otaBookingId)
if (booking != null) {
booking.status = BookingStatus.CANCELLED
bookingRepo.save(booking)
inbound.booking = booking
}
inbound.status = InboundEmailStatus.CANCELLED
inbound.processedAt = OffsetDateTime.now()
inboundEmailRepo.save(inbound)
return
}
val sourceBookingId = otaBookingId ?: fallbackKey
if (bookingRepo.existsByPropertyIdAndSourceBookingId(property.id!!, sourceBookingId)) {
inbound.status = InboundEmailStatus.SKIPPED
inbound.processedAt = OffsetDateTime.now()
inboundEmailRepo.save(inbound)
return
}
val guest = resolveGuest(property, extracted)
val emailUrl = "${publicBaseUrl}/properties/${property.id}/inbound-emails/${inbound.id}/file"
val booking = createBooking(property, guest, extracted, sourceBookingId, emailUrl)
inbound.booking = booking
inbound.status = InboundEmailStatus.CREATED
inbound.processedAt = OffsetDateTime.now()
inboundEmailRepo.save(inbound)
}
private fun buildMessageId(message: Message): String? { private fun buildMessageId(message: Message): String? {
val header = message.getHeader("Message-ID")?.firstOrNull() val header = message.getHeader("Message-ID")?.firstOrNull()
if (!header.isNullOrBlank()) return header if (!header.isNullOrBlank()) return header
@@ -253,10 +272,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 haystack = "${subject ?: ""}\n$body".lowercase()
val properties = propertyRepo.findAll() val properties = propertyRepo.findAll()
val matches = properties.filter { property -> val matches = properties.filter { property ->
if (recipients.isNotEmpty()) {
val propertyEmails = property.emailAddresses.map { it.lowercase() }.toSet()
if (propertyEmails.isNotEmpty() && recipients.any { it.lowercase() in propertyEmails }) {
return@filter true
}
}
val aliases = mutableSetOf<String>() val aliases = mutableSetOf<String>()
aliases.add(property.name) aliases.add(property.name)
aliases.add(property.code) aliases.add(property.code)
@@ -267,6 +292,15 @@ class EmailIngestionService(
return if (matches.size == 1) matches.first() else null return if (matches.size == 1) matches.first() else null
} }
private fun extractRecipients(message: Message): List<String> {
val list = mutableListOf<String>()
val to = message.getRecipients(Message.RecipientType.TO)
val cc = message.getRecipients(Message.RecipientType.CC)
(to ?: emptyArray()).forEach { list.add(it.toString()) }
(cc ?: emptyArray()).forEach { list.add(it.toString()) }
return list
}
private fun extractText(message: Message): String { private fun extractText(message: Message): String {
return try { return try {
val content = message.content val content = message.content
@@ -280,6 +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 { private fun extractFromMultipart(multipart: Multipart): String {
var text: String? = null var text: String? = null
var html: String? = null var html: String? = null

View File

@@ -1,2 +1,3 @@
spring.datasource.url=jdbc:postgresql://localhost:5432/trisolaris spring.datasource.url=jdbc:postgresql://localhost:5432/trisolaris
ai.llama.baseUrl=http://localhost:8089/v1/chat/completions 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.tokenSecret=change-me
storage.documents.tokenTtlSeconds=300 storage.documents.tokenTtlSeconds=300
storage.emails.root=/home/androidlover5842/docs/emails storage.emails.root=/home/androidlover5842/docs/emails
storage.emails.publicBaseUrl=https://api.hoteltrisolaris.in
storage.rooms.root=/home/androidlover5842/docs/rooms
storage.rooms.publicBaseUrl=https://api.hoteltrisolaris.in
mail.imap.host=localhost mail.imap.host=localhost
mail.imap.port=993 mail.imap.port=993
mail.imap.protocol=imaps mail.imap.protocol=imaps
@@ -17,3 +20,4 @@ mail.imap.username=
mail.imap.password= mail.imap.password=
mail.imap.pollMs=60000 mail.imap.pollMs=60000
mail.imap.enabled=false mail.imap.enabled=false
server.port=18921