Compare commits
79 Commits
9300a85bd3
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46f9fecf4a | ||
|
|
fb1c0caed7 | ||
|
|
4fdfc84811 | ||
|
|
19153900fd | ||
|
|
eb0b99f55a | ||
|
|
c3ec6e8d4a | ||
|
|
3a8f871d7d | ||
|
|
a0a9ce4d31 | ||
|
|
f9c31a4d59 | ||
|
|
236c885954 | ||
|
|
a39a9dcd1f | ||
|
|
40a09d1c83 | ||
|
|
0104e87050 | ||
|
|
1082126f86 | ||
|
|
188738e28b | ||
|
|
7f7e164acf | ||
|
|
c2c54d24f5 | ||
|
|
9aa1f71c32 | ||
|
|
ad1ef6ec0a | ||
|
|
8f6e645573 | ||
|
|
d38a29111d | ||
|
|
4f2eb3d671 | ||
|
|
8c4afb8232 | ||
|
|
be814eb0d5 | ||
|
|
3f05484498 | ||
|
|
d32c89d768 | ||
|
|
ce75e9536c | ||
|
|
0efce2f900 | ||
|
|
22a9fdc851 | ||
|
|
1400451bfe | ||
|
|
bf87d329d4 | ||
|
|
63c7479c9f | ||
|
|
721580ffd7 | ||
|
|
650e7c7354 | ||
|
|
619a48dd4f | ||
|
|
e3a7053d78 | ||
|
|
85254b229f | ||
|
|
2c337b8709 | ||
|
|
7ec8d6d350 | ||
|
|
8f47725f13 | ||
|
|
398ad93232 | ||
|
|
05b8fd409c | ||
|
|
6f961cb599 | ||
|
|
d895c4411d | ||
|
|
397bc4ede3 | ||
|
|
e1680b1991 | ||
|
|
14f739a54f | ||
|
|
3a5726203c | ||
|
|
32af3e0d82 | ||
|
|
4ec4e5e068 | ||
|
|
6963a0f252 | ||
|
|
812bb4ffb4 | ||
|
|
152bbbc575 | ||
|
|
671922b363 | ||
|
|
41299e6071 | ||
|
|
8887e456ac | ||
|
|
4f22bfa234 | ||
|
|
7b1bb55008 | ||
|
|
9ff84c2111 | ||
|
|
1f43f3274a | ||
|
|
21fd32aeee | ||
|
|
b972563971 | ||
|
|
af280ca88c | ||
|
|
e8d9590a37 | ||
|
|
97072e314a | ||
|
|
0afcc9b735 | ||
|
|
a206041b0c | ||
|
|
8997cffc16 | ||
|
|
b2441fe291 | ||
|
|
de7e293097 | ||
|
|
31398d3822 | ||
|
|
9835435aab | ||
|
|
094673b475 | ||
|
|
ab7f02ddc6 | ||
|
|
4a2834819f | ||
|
|
57ca477c08 | ||
|
|
72d9f5bb12 | ||
|
|
6b6d84e40a | ||
|
|
0d3472c60e |
32
.gitea/workflows/build.yml
Normal file
32
.gitea/workflows/build.yml
Normal 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
341
AGENTS.md
@@ -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.
|
||||||
|
|||||||
@@ -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
8
gradle.properties
Normal 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
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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?
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package com.android.trisolarisserver.controller
|
||||||
|
|
||||||
|
import com.android.trisolarisserver.component.PropertyAccess
|
||||||
|
import com.android.trisolarisserver.component.RoomImageStorage
|
||||||
|
import com.android.trisolarisserver.controller.dto.RoomImageResponse
|
||||||
|
import com.android.trisolarisserver.models.room.RoomImage
|
||||||
|
import com.android.trisolarisserver.models.property.Role
|
||||||
|
import com.android.trisolarisserver.repo.RoomImageRepo
|
||||||
|
import com.android.trisolarisserver.repo.RoomRepo
|
||||||
|
import com.android.trisolarisserver.security.MyPrincipal
|
||||||
|
import org.springframework.core.io.FileSystemResource
|
||||||
|
import org.springframework.http.HttpHeaders
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.http.MediaType
|
||||||
|
import org.springframework.http.ResponseEntity
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
|
import org.springframework.web.bind.annotation.ResponseStatus
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import org.springframework.web.multipart.MultipartFile
|
||||||
|
import org.springframework.web.server.ResponseStatusException
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.Paths
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/properties/{propertyId}/rooms/{roomId}/images")
|
||||||
|
class RoomImages(
|
||||||
|
private val propertyAccess: PropertyAccess,
|
||||||
|
private val roomRepo: RoomRepo,
|
||||||
|
private val roomImageRepo: RoomImageRepo,
|
||||||
|
private val storage: RoomImageStorage,
|
||||||
|
@org.springframework.beans.factory.annotation.Value("\${storage.rooms.publicBaseUrl}")
|
||||||
|
private val publicBaseUrl: String
|
||||||
|
) {
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
fun list(
|
||||||
|
@PathVariable propertyId: UUID,
|
||||||
|
@PathVariable roomId: UUID,
|
||||||
|
@AuthenticationPrincipal principal: MyPrincipal?
|
||||||
|
): List<RoomImageResponse> {
|
||||||
|
requirePrincipal(principal)
|
||||||
|
propertyAccess.requireMember(propertyId, principal!!.userId)
|
||||||
|
ensureRoom(propertyId, roomId)
|
||||||
|
return roomImageRepo.findByRoomIdOrderByCreatedAtDesc(roomId)
|
||||||
|
.map { it.toResponse(publicBaseUrl) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
|
fun upload(
|
||||||
|
@PathVariable propertyId: UUID,
|
||||||
|
@PathVariable roomId: UUID,
|
||||||
|
@AuthenticationPrincipal principal: MyPrincipal?,
|
||||||
|
@RequestParam("file") file: MultipartFile
|
||||||
|
): RoomImageResponse {
|
||||||
|
requirePrincipal(principal)
|
||||||
|
propertyAccess.requireMember(propertyId, principal!!.userId)
|
||||||
|
propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN, Role.MANAGER)
|
||||||
|
val room = ensureRoom(propertyId, roomId)
|
||||||
|
|
||||||
|
if (file.isEmpty) {
|
||||||
|
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "File is empty")
|
||||||
|
}
|
||||||
|
val stored = try {
|
||||||
|
storage.store(propertyId, roomId, file)
|
||||||
|
} catch (ex: IllegalArgumentException) {
|
||||||
|
throw ResponseStatusException(HttpStatus.BAD_REQUEST, ex.message ?: "Invalid image")
|
||||||
|
}
|
||||||
|
|
||||||
|
val image = RoomImage(
|
||||||
|
property = room.property,
|
||||||
|
room = room,
|
||||||
|
originalPath = stored.originalPath,
|
||||||
|
thumbnailPath = stored.thumbnailPath,
|
||||||
|
contentType = stored.contentType,
|
||||||
|
sizeBytes = stored.sizeBytes
|
||||||
|
)
|
||||||
|
return roomImageRepo.save(image).toResponse(publicBaseUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{imageId}/file")
|
||||||
|
fun file(
|
||||||
|
@PathVariable propertyId: UUID,
|
||||||
|
@PathVariable roomId: UUID,
|
||||||
|
@PathVariable imageId: UUID,
|
||||||
|
@RequestParam(required = false, defaultValue = "full") size: String,
|
||||||
|
@AuthenticationPrincipal principal: MyPrincipal?
|
||||||
|
): ResponseEntity<FileSystemResource> {
|
||||||
|
requirePrincipal(principal)
|
||||||
|
propertyAccess.requireMember(propertyId, principal!!.userId)
|
||||||
|
ensureRoom(propertyId, roomId)
|
||||||
|
|
||||||
|
val image = roomImageRepo.findByIdAndRoomIdAndPropertyId(imageId, roomId, propertyId)
|
||||||
|
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Image not found")
|
||||||
|
val path = if (size.equals("thumb", true)) image.thumbnailPath else image.originalPath
|
||||||
|
val file = Paths.get(path)
|
||||||
|
if (!Files.exists(file)) {
|
||||||
|
throw ResponseStatusException(HttpStatus.NOT_FOUND, "File missing")
|
||||||
|
}
|
||||||
|
val resource = FileSystemResource(file)
|
||||||
|
val type = image.contentType
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.contentType(MediaType.parseMediaType(type))
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"room-${imageId}.${file.fileName}\"")
|
||||||
|
.contentLength(resource.contentLength())
|
||||||
|
.body(resource)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureRoom(propertyId: UUID, roomId: UUID): com.android.trisolarisserver.models.room.Room {
|
||||||
|
return roomRepo.findByIdAndPropertyId(roomId, propertyId)
|
||||||
|
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requirePrincipal(principal: MyPrincipal?) {
|
||||||
|
if (principal == null) {
|
||||||
|
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun RoomImage.toResponse(baseUrl: String): RoomImageResponse {
|
||||||
|
val id = id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Image id missing")
|
||||||
|
return RoomImageResponse(
|
||||||
|
id = id,
|
||||||
|
propertyId = property.id!!,
|
||||||
|
roomId = room.id!!,
|
||||||
|
url = "$baseUrl/properties/${property.id}/rooms/${room.id}/images/$id/file",
|
||||||
|
thumbnailUrl = "$baseUrl/properties/${property.id}/rooms/${room.id}/images/$id/file?size=thumb",
|
||||||
|
contentType = contentType,
|
||||||
|
sizeBytes = sizeBytes,
|
||||||
|
createdAt = createdAt.toString()
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package com.android.trisolarisserver.controller
|
||||||
|
|
||||||
|
import com.android.trisolarisserver.component.PropertyAccess
|
||||||
|
import com.android.trisolarisserver.component.RoomBoardEvents
|
||||||
|
import com.android.trisolarisserver.controller.dto.RoomChangeRequest
|
||||||
|
import com.android.trisolarisserver.controller.dto.RoomChangeResponse
|
||||||
|
import com.android.trisolarisserver.models.room.RoomStay
|
||||||
|
import com.android.trisolarisserver.models.room.RoomStayChange
|
||||||
|
import com.android.trisolarisserver.models.property.Role
|
||||||
|
import com.android.trisolarisserver.repo.AppUserRepo
|
||||||
|
import com.android.trisolarisserver.repo.RoomRepo
|
||||||
|
import com.android.trisolarisserver.repo.RoomStayChangeRepo
|
||||||
|
import com.android.trisolarisserver.repo.RoomStayRepo
|
||||||
|
import com.android.trisolarisserver.security.MyPrincipal
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.ResponseStatus
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import org.springframework.web.server.ResponseStatusException
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/properties/{propertyId}/room-stays")
|
||||||
|
class RoomStayFlow(
|
||||||
|
private val propertyAccess: PropertyAccess,
|
||||||
|
private val roomStayRepo: RoomStayRepo,
|
||||||
|
private val roomStayChangeRepo: RoomStayChangeRepo,
|
||||||
|
private val roomRepo: RoomRepo,
|
||||||
|
private val appUserRepo: AppUserRepo,
|
||||||
|
private val roomBoardEvents: RoomBoardEvents
|
||||||
|
) {
|
||||||
|
|
||||||
|
@PostMapping("/{roomStayId}/change-room")
|
||||||
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
|
@Transactional
|
||||||
|
fun changeRoom(
|
||||||
|
@PathVariable propertyId: UUID,
|
||||||
|
@PathVariable roomStayId: UUID,
|
||||||
|
@AuthenticationPrincipal principal: MyPrincipal?,
|
||||||
|
@RequestBody request: RoomChangeRequest
|
||||||
|
): RoomChangeResponse {
|
||||||
|
val actor = requireActor(propertyId, principal)
|
||||||
|
|
||||||
|
val stay = roomStayRepo.findById(roomStayId).orElseThrow {
|
||||||
|
ResponseStatusException(HttpStatus.NOT_FOUND, "Room stay not found")
|
||||||
|
}
|
||||||
|
if (stay.property.id != propertyId) {
|
||||||
|
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room stay not found for property")
|
||||||
|
}
|
||||||
|
if (stay.toAt != null) {
|
||||||
|
throw ResponseStatusException(HttpStatus.CONFLICT, "Room stay already closed")
|
||||||
|
}
|
||||||
|
if (request.idempotencyKey.isBlank()) {
|
||||||
|
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "idempotencyKey required")
|
||||||
|
}
|
||||||
|
if (request.newRoomId == stay.room.id) {
|
||||||
|
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "New room is same as current")
|
||||||
|
}
|
||||||
|
|
||||||
|
val existing = roomStayChangeRepo.findByRoomStayIdAndIdempotencyKey(roomStayId, request.idempotencyKey)
|
||||||
|
if (existing != null) {
|
||||||
|
return toResponse(existing)
|
||||||
|
}
|
||||||
|
|
||||||
|
val newRoom = roomRepo.findByIdAndPropertyId(request.newRoomId, propertyId)
|
||||||
|
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room not found")
|
||||||
|
if (!newRoom.active || newRoom.maintenance) {
|
||||||
|
throw ResponseStatusException(HttpStatus.CONFLICT, "Room not available")
|
||||||
|
}
|
||||||
|
val occupied = roomStayRepo.findActiveRoomIds(propertyId, listOf(request.newRoomId))
|
||||||
|
if (occupied.isNotEmpty()) {
|
||||||
|
throw ResponseStatusException(HttpStatus.CONFLICT, "Room already occupied")
|
||||||
|
}
|
||||||
|
|
||||||
|
val movedAt = parseOffset(request.movedAt) ?: OffsetDateTime.now()
|
||||||
|
|
||||||
|
stay.toAt = movedAt
|
||||||
|
roomStayRepo.save(stay)
|
||||||
|
|
||||||
|
val newStay = RoomStay(
|
||||||
|
property = stay.property,
|
||||||
|
booking = stay.booking,
|
||||||
|
room = newRoom,
|
||||||
|
fromAt = movedAt,
|
||||||
|
toAt = null,
|
||||||
|
createdBy = actor
|
||||||
|
)
|
||||||
|
val savedNewStay = roomStayRepo.save(newStay)
|
||||||
|
|
||||||
|
val change = RoomStayChange(
|
||||||
|
property = stay.property,
|
||||||
|
roomStay = stay,
|
||||||
|
newRoomStay = savedNewStay,
|
||||||
|
idempotencyKey = request.idempotencyKey
|
||||||
|
)
|
||||||
|
val savedChange = roomStayChangeRepo.save(change)
|
||||||
|
roomBoardEvents.emit(propertyId)
|
||||||
|
return toResponse(savedChange)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toResponse(change: RoomStayChange): RoomChangeResponse {
|
||||||
|
return RoomChangeResponse(
|
||||||
|
oldRoomStayId = change.roomStay.id!!,
|
||||||
|
newRoomStayId = change.newRoomStay.id!!,
|
||||||
|
oldRoomId = change.roomStay.room.id!!,
|
||||||
|
newRoomId = change.newRoomStay.room.id!!,
|
||||||
|
movedAt = change.newRoomStay.fromAt.toString()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseOffset(value: String?): OffsetDateTime? {
|
||||||
|
if (value.isNullOrBlank()) return null
|
||||||
|
return try {
|
||||||
|
OffsetDateTime.parse(value.trim())
|
||||||
|
} catch (_: Exception) {
|
||||||
|
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid timestamp")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requireActor(propertyId: UUID, principal: MyPrincipal?): com.android.trisolarisserver.models.property.AppUser {
|
||||||
|
if (principal == null) {
|
||||||
|
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
|
||||||
|
}
|
||||||
|
propertyAccess.requireMember(propertyId, principal.userId)
|
||||||
|
propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN, Role.MANAGER, Role.STAFF)
|
||||||
|
return appUserRepo.findById(principal.userId).orElseThrow {
|
||||||
|
ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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?
|
||||||
|
)
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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?
|
||||||
|
)
|
||||||
@@ -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?
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>>
|
||||||
|
}
|
||||||
@@ -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?
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package com.android.trisolarisserver.models.booking
|
||||||
|
|
||||||
|
enum class GuestRatingScore {
|
||||||
|
GOOD, OK, TROUBLE
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.android.trisolarisserver.models.booking
|
||||||
|
|
||||||
|
enum class TransportMode {
|
||||||
|
CAR,
|
||||||
|
BIKE,
|
||||||
|
TRAIN,
|
||||||
|
PLANE,
|
||||||
|
BUS,
|
||||||
|
FOOT,
|
||||||
|
CYCLE,
|
||||||
|
OTHER
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|
||||||
|
|||||||
@@ -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() }
|
|
||||||
@@ -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()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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?
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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?
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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?
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user