Compare commits

...

64 Commits

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

View File

@@ -1,32 +1,32 @@
name: build name: build-and-deploy
on: on:
push: push:
branches: [ "**" ] branches: [ "master" ]
pull_request:
branches: [ "**" ]
jobs: jobs:
build: build-deploy:
runs-on: ubuntu-latest:host runs-on: ubuntu-latest
timeout-minutes: 30
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up JDK 19 - name: Set up JDK 21
uses: actions/setup-java@v4 uses: actions/setup-java@v4
with: with:
distribution: temurin distribution: temurin
java-version: "19" java-version: "21"
cache: gradle cache: gradle
- name: Build - name: Build (skip tests)
run: ./gradlew -q assemble 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
deploy: - name: Deploy jar and restart
runs-on: ubuntu-latest:host
needs: build
steps:
- name: Trigger deploy webhook
run: | run: |
curl -sS -X POST http://127.0.0.1:9000/hooks/deploy-trisolarisserver set -e
mkdir -p /opt/deploy/TrisolarisServer/build/libs
cp -f build/libs/*.jar /opt/deploy/TrisolarisServer/build/libs/
sudo systemctl restart TrisolarisServer.service

View File

@@ -22,7 +22,7 @@ Core principles
- Room availability by room number; toAt=null means occupied. - Room availability by room number; toAt=null means occupied.
- Room change = close old RoomStay + open new one. - Room change = close old RoomStay + open new one.
- Multi-property: every domain object scoped to property_id. - Multi-property: every domain object scoped to property_id.
- Users belong to org; access granted per property. - AppUser is global; access granted per property.
Immutable rules Immutable rules
- Use Kotlin only; no microservices. - Use Kotlin only; no microservices.
@@ -46,19 +46,18 @@ Security/Auth
- /auth/verify and /auth/me. - /auth/verify and /auth/me.
Domain entities Domain entities
- Organization: name, emailAliases, allowedTransportModes.
- Property: code, name, addressText, emailAddresses, otaAliases, allowedTransportModes. - Property: code, name, addressText, emailAddresses, otaAliases, allowedTransportModes.
- AppUser, PropertyUser (roles per property). - AppUser (global, superAdmin), PropertyUser (roles per property).
- RoomType: code/name/occupancy + otaAliases. - RoomType: code/name/occupancy + otaAliases.
- Room: roomNumber, floor, hasNfc, active, maintenance, notes. - Room: roomNumber, floor, hasNfc, active, maintenance, notes.
- Booking: status, expected check-in/out, emailAuditPdfUrl, transportMode, transportVehicleNumber. - Booking: status, expected check-in/out, emailAuditPdfUrl, transportMode, transportVehicleNumber.
- Guest (org-scoped). - Guest (property-scoped).
- RoomStay. - RoomStay.
- RoomStayChange (idempotent room move). - RoomStayChange (idempotent room move).
- IssuedCard (cardId, cardIndex, issuedAt, expiresAt, issuedBy, revokedAt). - IssuedCard (cardId, cardIndex, issuedAt, expiresAt, issuedBy, revokedAt).
- PropertyCardCounter (per-property cardIndex counter). - PropertyCardCounter (per-property cardIndex counter).
- GuestDocument (files + AI-extracted json). - GuestDocument (files + AI-extracted json).
- GuestVehicle (org-scoped vehicle numbers). - GuestVehicle (property-scoped vehicle numbers).
- InboundEmail (audit PDF + raw EML, extracted json, status). - InboundEmail (audit PDF + raw EML, extracted json, status).
- RoomImage (original + thumbnail). - RoomImage (original + thumbnail).
@@ -68,14 +67,10 @@ Auth
- /auth/verify - /auth/verify
- /auth/me - /auth/me
Organizations / Properties / Users Properties / Users
- POST /orgs - POST /properties (creator becomes ADMIN on that property)
- GET /orgs/{orgId} - GET /properties (super admin gets all; others get memberships)
- POST /orgs/{orgId}/properties
- GET /orgs/{orgId}/properties
- PUT /properties/{propertyId} - PUT /properties/{propertyId}
- GET /orgs/{orgId}/users
- POST /orgs/{orgId}/users (removed; users created by app)
- GET /properties/{propertyId}/users - GET /properties/{propertyId}/users
- PUT /properties/{propertyId}/users/{userId}/roles - PUT /properties/{propertyId}/users/{userId}/roles
- DELETE /properties/{propertyId}/users/{userId} (ADMIN only) - DELETE /properties/{propertyId}/users/{userId} (ADMIN only)
@@ -93,9 +88,8 @@ Room types
- PUT /properties/{propertyId}/room-types/{roomTypeId} - PUT /properties/{propertyId}/room-types/{roomTypeId}
- DELETE /properties/{propertyId}/room-types/{roomTypeId} - DELETE /properties/{propertyId}/room-types/{roomTypeId}
Properties / Orgs Properties
- Property create/update accepts addressText, otaAliases, emailAddresses, allowedTransportModes. - Property create/update accepts addressText, otaAliases, emailAddresses, allowedTransportModes.
- Org create/get returns emailAliases + allowedTransportModes.
Booking flow Booking flow
- /properties/{propertyId}/bookings/{bookingId}/check-in (creates RoomStay rows) - /properties/{propertyId}/bookings/{bookingId}/check-in (creates RoomStay rows)
@@ -126,7 +120,7 @@ Room images
- Thumbnails generated (320px). - Thumbnails generated (320px).
Transport modes Transport modes
- /properties/{propertyId}/transport-modes -> returns enabled list (property > org > default all). - /properties/{propertyId}/transport-modes -> returns enabled list (property or default all).
Inbound email ingestion Inbound email ingestion
- IMAP poller (1 min) with enable flag. - IMAP poller (1 min) with enable flag.
@@ -153,5 +147,6 @@ Config
Notes / constraints Notes / constraints
- Users are created by app; API only manages roles. - Users are created by app; API only manages roles.
- Super admin can create properties and assign users to properties.
- Admin can assign ADMIN/MANAGER/STAFF/AGENT; Manager can assign STAFF/AGENT. - Admin can assign ADMIN/MANAGER/STAFF/AGENT; Manager can assign STAFF/AGENT.
- Agents can only see free rooms. - Agents can only see free rooms.

View File

@@ -12,7 +12,7 @@ description = "TrisolarisServer"
java { java {
toolchain { toolchain {
languageVersion = JavaLanguageVersion.of(19) languageVersion = JavaLanguageVersion.of(21)
} }
} }

8
gradle.properties Normal file
View File

@@ -0,0 +1,8 @@
org.gradle.caching=true
org.gradle.configuration-cache=true
org.gradle.parallel=true
org.gradle.vfs.watch=true
org.gradle.daemon=false
org.gradle.jvmargs=-Xmx10g -Dfile.encoding=UTF-8
kotlin.incremental=true

View File

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

View File

@@ -0,0 +1,71 @@
package com.android.trisolarisserver.config
import jakarta.servlet.http.HttpServletRequest
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.security.access.AccessDeniedException
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice
import org.springframework.web.server.ResponseStatusException
import java.time.OffsetDateTime
@RestControllerAdvice
class ApiExceptionHandler {
@ExceptionHandler(ResponseStatusException::class)
fun handleResponseStatus(
ex: ResponseStatusException,
request: HttpServletRequest
): ResponseEntity<ApiError> {
val status = ex.statusCode as HttpStatus
return ResponseEntity.status(status).body(
ApiError(
timestamp = OffsetDateTime.now().toString(),
status = status.value(),
error = status.reasonPhrase,
message = ex.reason ?: "Request failed",
path = request.requestURI
)
)
}
@ExceptionHandler(AccessDeniedException::class)
fun handleAccessDenied(
ex: AccessDeniedException,
request: HttpServletRequest
): ResponseEntity<ApiError> {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(
ApiError(
timestamp = OffsetDateTime.now().toString(),
status = HttpStatus.FORBIDDEN.value(),
error = HttpStatus.FORBIDDEN.reasonPhrase,
message = ex.message ?: "Forbidden",
path = request.requestURI
)
)
}
@ExceptionHandler(Exception::class)
fun handleGeneric(
ex: Exception,
request: HttpServletRequest
): ResponseEntity<ApiError> {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(
ApiError(
timestamp = OffsetDateTime.now().toString(),
status = HttpStatus.INTERNAL_SERVER_ERROR.value(),
error = HttpStatus.INTERNAL_SERVER_ERROR.reasonPhrase,
message = ex.message ?: "Internal server error",
path = request.requestURI
)
)
}
}
data class ApiError(
val timestamp: String,
val status: Int,
val error: String,
val message: String,
val path: String
)

View File

@@ -5,13 +5,20 @@ import com.android.trisolarisserver.controller.dto.UserResponse
import com.android.trisolarisserver.repo.AppUserRepo import com.android.trisolarisserver.repo.AppUserRepo
import com.android.trisolarisserver.repo.PropertyUserRepo import com.android.trisolarisserver.repo.PropertyUserRepo
import com.android.trisolarisserver.security.MyPrincipal import com.android.trisolarisserver.security.MyPrincipal
import com.google.firebase.auth.FirebaseAuth
import jakarta.servlet.http.HttpServletRequest
import org.slf4j.LoggerFactory
import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import org.springframework.web.server.ResponseStatusException import org.springframework.web.server.ResponseStatusException
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import java.util.UUID
@RestController @RestController
@RequestMapping("/auth") @RequestMapping("/auth")
@@ -19,21 +26,49 @@ class Auth(
private val appUserRepo: AppUserRepo, private val appUserRepo: AppUserRepo,
private val propertyUserRepo: PropertyUserRepo private val propertyUserRepo: PropertyUserRepo
) { ) {
private val logger = LoggerFactory.getLogger(Auth::class.java)
@PostMapping("/verify") @PostMapping("/verify")
fun verify(@AuthenticationPrincipal principal: MyPrincipal?): AuthResponse { fun verify(
return buildAuthResponse(principal) @AuthenticationPrincipal principal: MyPrincipal?,
request: HttpServletRequest
): ResponseEntity<AuthResponse> {
logger.info("Auth verify hit, principalPresent={}", principal != null)
val resolved = principal?.let { ResolveResult(it) } ?: resolvePrincipalFromHeader(request)
return resolved.toResponseEntity()
} }
@GetMapping("/me") @GetMapping("/me")
fun me(@AuthenticationPrincipal principal: MyPrincipal?): AuthResponse { fun me(
return buildAuthResponse(principal) @AuthenticationPrincipal principal: MyPrincipal?,
request: HttpServletRequest
): ResponseEntity<AuthResponse> {
val resolved = principal?.let { ResolveResult(it) } ?: resolvePrincipalFromHeader(request)
return resolved.toResponseEntity()
} }
private fun buildAuthResponse(principal: MyPrincipal?): AuthResponse { @PutMapping("/me")
if (principal == null) { fun updateMe(
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal") @AuthenticationPrincipal principal: MyPrincipal?,
request: HttpServletRequest,
@RequestBody body: UpdateMeRequest
): ResponseEntity<AuthResponse> {
val resolved = principal?.let { ResolveResult(it) } ?: resolvePrincipalFromHeader(request)
if (resolved.principal == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(AuthResponse(status = "UNAUTHORIZED"))
} }
val user = appUserRepo.findById(resolved.principal.userId).orElseThrow {
ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found")
}
if (!body.name.isNullOrBlank()) {
user.name = body.name.trim()
}
appUserRepo.save(user)
return ResponseEntity.ok(buildAuthResponse(resolved.principal))
}
private fun buildAuthResponse(principal: MyPrincipal): AuthResponse {
val user = appUserRepo.findById(principal.userId).orElseThrow { val user = appUserRepo.findById(principal.userId).orElseThrow {
ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found") ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found")
} }
@@ -44,21 +79,85 @@ class Auth(
roles = it.roles.map { role -> role.name }.toSet() roles = it.roles.map { role -> role.name }.toSet()
) )
} }
val status = when {
user.superAdmin -> "SUPER_ADMIN"
memberships.isEmpty() -> "NO_PROPERTIES"
else -> "OK"
}
return AuthResponse( return AuthResponse(
status = status,
user = UserResponse( user = UserResponse(
id = user.id!!, id = user.id!!,
orgId = user.org.id!!,
firebaseUid = user.firebaseUid, firebaseUid = user.firebaseUid,
phoneE164 = user.phoneE164, phoneE164 = user.phoneE164,
name = user.name, name = user.name,
disabled = user.disabled disabled = user.disabled,
superAdmin = user.superAdmin
), ),
properties = memberships properties = memberships
) )
} }
private fun resolvePrincipalFromHeader(request: HttpServletRequest): ResolveResult {
val header = request.getHeader("Authorization") ?: throw ResponseStatusException(
HttpStatus.UNAUTHORIZED,
"Missing Authorization token"
)
if (!header.startsWith("Bearer ")) {
logger.warn("Auth verify invalid Authorization header")
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid Authorization header")
}
val token = header.removePrefix("Bearer ").trim()
val decoded = try {
FirebaseAuth.getInstance().verifyIdToken(token)
} catch (ex: Exception) {
logger.warn("Auth verify failed: {}", ex.message)
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid token")
}
val user = appUserRepo.findByFirebaseUid(decoded.uid) ?: run {
val phone = decoded.claims["phone_number"] as? String
val name = decoded.claims["name"] as? String
val makeSuperAdmin = appUserRepo.count() == 0L
val created = appUserRepo.save(
com.android.trisolarisserver.models.property.AppUser(
firebaseUid = decoded.uid,
phoneE164 = phone,
name = name,
superAdmin = makeSuperAdmin
)
)
logger.warn("Auth verify auto-created user uid={}, userId={}", decoded.uid, created.id)
created
}
logger.warn("Auth verify resolved uid={}, userId={}", decoded.uid, user.id)
return ResolveResult(
MyPrincipal(
userId = user.id ?: throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "User id missing"),
firebaseUid = decoded.uid
)
)
}
private fun ResolveResult.toResponseEntity(): ResponseEntity<AuthResponse> {
return if (principal == null) {
ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(AuthResponse(status = "UNAUTHORIZED"))
} else {
ResponseEntity.ok(buildAuthResponse(principal))
}
}
} }
data class AuthResponse( data class AuthResponse(
val user: UserResponse, val status: String,
val properties: List<PropertyUserResponse> val user: UserResponse? = null,
val properties: List<PropertyUserResponse> = emptyList()
)
data class UpdateMeRequest(
val name: String? = null
)
private data class ResolveResult(
val principal: MyPrincipal?
) )

View File

@@ -261,10 +261,10 @@ class BookingFlow(
property: com.android.trisolarisserver.models.property.Property, property: com.android.trisolarisserver.models.property.Property,
mode: TransportMode mode: TransportMode
): Boolean { ): Boolean {
val allowed = when { val allowed = if (property.allowedTransportModes.isNotEmpty()) {
property.allowedTransportModes.isNotEmpty() -> property.allowedTransportModes property.allowedTransportModes
property.org.allowedTransportModes.isNotEmpty() -> property.org.allowedTransportModes } else {
else -> TransportMode.entries.toSet() TransportMode.entries.toSet()
} }
return allowed.contains(mode) return allowed.contains(mode)
} }

View File

@@ -71,8 +71,8 @@ class GuestDocuments(
val guest = guestRepo.findById(guestId).orElseThrow { val guest = guestRepo.findById(guestId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Guest not found") ResponseStatusException(HttpStatus.NOT_FOUND, "Guest not found")
} }
if (guest.org.id != property.org.id) { if (guest.property.id != property.id) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Guest not in property org") throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Guest not in property")
} }
val booking = bookingRepo.findById(bookingId).orElseThrow { val booking = bookingRepo.findById(bookingId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found") ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found")

View File

@@ -51,8 +51,8 @@ class GuestRatings(
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(request.bookingId).orElseThrow { val booking = bookingRepo.findById(request.bookingId).orElseThrow {
@@ -70,7 +70,6 @@ class GuestRatings(
val score = parseScore(request.score) val score = parseScore(request.score)
val rating = GuestRating( val rating = GuestRating(
org = property.org,
property = property, property = property,
guest = guest, guest = guest,
booking = booking, booking = booking,
@@ -97,8 +96,8 @@ class GuestRatings(
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")
} }
return guestRatingRepo.findByGuestIdOrderByCreatedAtDesc(guestId).map { it.toResponse() } return guestRatingRepo.findByGuestIdOrderByCreatedAtDesc(guestId).map { it.toResponse() }
@@ -117,7 +116,6 @@ class GuestRatings(
private fun GuestRating.toResponse(): GuestRatingResponse { private fun GuestRating.toResponse(): GuestRatingResponse {
return GuestRatingResponse( return GuestRatingResponse(
id = id!!, id = id!!,
orgId = org.id!!,
propertyId = property.id!!, propertyId = property.id!!,
guestId = guest.id!!, guestId = guest.id!!,
bookingId = booking.id!!, bookingId = booking.id!!,

View File

@@ -43,15 +43,14 @@ class Guests(
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 orgId = property.org.id ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Org missing")
val guests = mutableSetOf<Guest>() val guests = mutableSetOf<Guest>()
if (!phone.isNullOrBlank()) { if (!phone.isNullOrBlank()) {
val guest = guestRepo.findByOrgIdAndPhoneE164(orgId, phone) val guest = guestRepo.findByPropertyIdAndPhoneE164(propertyId, phone)
if (guest != null) guests.add(guest) if (guest != null) guests.add(guest)
} }
if (!vehicleNumber.isNullOrBlank()) { if (!vehicleNumber.isNullOrBlank()) {
val vehicle = guestVehicleRepo.findByOrgIdAndVehicleNumberIgnoreCase(orgId, vehicleNumber) val vehicle = guestVehicleRepo.findByPropertyIdAndVehicleNumberIgnoreCase(propertyId, vehicleNumber)
if (vehicle != null) guests.add(vehicle.guest) if (vehicle != null) guests.add(vehicle.guest)
} }
return guests.toResponse(guestVehicleRepo, guestRatingRepo) return guests.toResponse(guestVehicleRepo, guestRatingRepo)
@@ -74,15 +73,15 @@ class Guests(
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")
} }
if (guestVehicleRepo.existsByOrgIdAndVehicleNumberIgnoreCase(property.org.id!!, request.vehicleNumber)) { if (guestVehicleRepo.existsByPropertyIdAndVehicleNumberIgnoreCase(property.id!!, request.vehicleNumber)) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Vehicle number already exists") throw ResponseStatusException(HttpStatus.CONFLICT, "Vehicle number already exists")
} }
val vehicle = GuestVehicle( val vehicle = GuestVehicle(
org = property.org, property = property,
guest = guest, guest = guest,
vehicleNumber = request.vehicleNumber.trim() vehicleNumber = request.vehicleNumber.trim()
) )
@@ -116,7 +115,6 @@ private fun Set<Guest>.toResponse(
return this.map { guest -> return this.map { guest ->
GuestResponse( GuestResponse(
id = guest.id!!, id = guest.id!!,
orgId = guest.org.id!!,
name = guest.name, name = guest.name,
phoneE164 = guest.phoneE164, phoneE164 = guest.phoneE164,
nationality = guest.nationality, nationality = guest.nationality,

View File

@@ -7,11 +7,11 @@ import org.springframework.web.bind.annotation.RestController
class Health { class Health {
@GetMapping("/health") @GetMapping("/health")
fun health(): Map<String, String> { fun health(): Map<String, String> {
return mapOf("status" to "ok") return mapOf("status" to "ok", "build" to "2026-01-26-authfix")
} }
@GetMapping("/") @GetMapping("/")
fun root(): Map<String, String> { fun root(): Map<String, String> {
return mapOf("status" to "ok") return mapOf("status" to "ok", "build" to "2026-01-26-authfix")
} }
} }

View File

@@ -1,95 +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.booking.TransportMode
import com.android.trisolarisserver.models.property.Organization
import com.android.trisolarisserver.models.property.Role
import com.android.trisolarisserver.security.MyPrincipal
import org.springframework.http.HttpStatus
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.server.ResponseStatusException
import java.util.UUID
@RestController
@RequestMapping("/orgs")
class Orgs(
private val orgRepo: OrganizationRepo,
private val appUserRepo: AppUserRepo,
private val propertyUserRepo: PropertyUserRepo
) {
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
fun createOrg(
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: OrgCreateRequest
): OrgResponse {
val user = requireUser(principal)
val orgId = user.org.id ?: throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Org missing")
if (!propertyUserRepo.hasAnyRoleInOrg(orgId, user.id!!, setOf(Role.ADMIN))) {
throw ResponseStatusException(HttpStatus.FORBIDDEN, "Missing role")
}
val org = Organization().apply {
name = request.name
emailAliases = request.emailAliases?.toMutableSet() ?: mutableSetOf()
if (request.allowedTransportModes != null) {
allowedTransportModes = parseTransportModes(request.allowedTransportModes)
}
}
val saved = orgRepo.save(org)
return OrgResponse(
id = saved.id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Org id missing"),
name = saved.name ?: "",
emailAliases = saved.emailAliases.toSet(),
allowedTransportModes = saved.allowedTransportModes.map { it.name }.toSet()
)
}
@GetMapping("/{orgId}")
fun getOrg(
@PathVariable orgId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
): OrgResponse {
val user = requireUser(principal)
if (user.org.id != orgId) {
throw ResponseStatusException(HttpStatus.FORBIDDEN, "No access to org")
}
val org = orgRepo.findById(orgId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Org not found")
}
return OrgResponse(
id = org.id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Org id missing"),
name = org.name ?: "",
emailAliases = org.emailAliases.toSet(),
allowedTransportModes = org.allowedTransportModes.map { it.name }.toSet()
)
}
private fun requireUser(principal: MyPrincipal?): com.android.trisolarisserver.models.property.AppUser {
if (principal == null) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
}
return appUserRepo.findById(principal.userId).orElseThrow {
ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found")
}
}
private fun parseTransportModes(modes: Set<String>): MutableSet<TransportMode> {
return try {
modes.map { TransportMode.valueOf(it) }.toMutableSet()
} catch (_: IllegalArgumentException) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown transport mode")
}
}
}

View File

@@ -6,9 +6,7 @@ import com.android.trisolarisserver.controller.dto.PropertyResponse
import com.android.trisolarisserver.controller.dto.PropertyUpdateRequest import com.android.trisolarisserver.controller.dto.PropertyUpdateRequest
import com.android.trisolarisserver.controller.dto.PropertyUserResponse import com.android.trisolarisserver.controller.dto.PropertyUserResponse
import com.android.trisolarisserver.controller.dto.PropertyUserRoleRequest import com.android.trisolarisserver.controller.dto.PropertyUserRoleRequest
import com.android.trisolarisserver.controller.dto.UserResponse
import com.android.trisolarisserver.repo.AppUserRepo import com.android.trisolarisserver.repo.AppUserRepo
import com.android.trisolarisserver.repo.OrganizationRepo
import com.android.trisolarisserver.repo.PropertyRepo import com.android.trisolarisserver.repo.PropertyRepo
import com.android.trisolarisserver.repo.PropertyUserRepo import com.android.trisolarisserver.repo.PropertyUserRepo
import com.android.trisolarisserver.models.property.Property import com.android.trisolarisserver.models.property.Property
@@ -34,33 +32,22 @@ 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,
@@ -72,33 +59,34 @@ class Properties(
allowedTransportModes = request.allowedTransportModes?.let { parseTransportModes(it) } ?: 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")
@@ -129,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()
@@ -153,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),
@@ -201,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
@@ -239,12 +226,6 @@ class Properties(
} }
} }
private fun requireOrgRole(orgId: UUID, userId: UUID, vararg roles: Role) {
if (!propertyUserRepo.hasAnyRoleInOrg(orgId, userId, roles.toSet())) {
throw ResponseStatusException(HttpStatus.FORBIDDEN, "Missing role")
}
}
private fun parseTransportModes(modes: Set<String>): MutableSet<TransportMode> { private fun parseTransportModes(modes: Set<String>): MutableSet<TransportMode> {
return try { return try {
modes.map { TransportMode.valueOf(it) }.toMutableSet() modes.map { TransportMode.valueOf(it) }.toMutableSet()
@@ -256,10 +237,8 @@ class Properties(
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,
@@ -271,16 +250,3 @@ private fun Property.toResponse(): PropertyResponse {
allowedTransportModes = allowedTransportModes.map { it.name }.toSet() allowedTransportModes = allowedTransportModes.map { it.name }.toSet()
) )
} }
private fun com.android.trisolarisserver.models.property.AppUser.toUserResponse(): UserResponse {
val id = this.id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "User id missing")
val orgId = this.org.id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Org id missing")
return UserResponse(
id = id,
orgId = orgId,
firebaseUid = firebaseUid,
phoneE164 = phoneE164,
name = name,
disabled = disabled
)
}

View File

@@ -0,0 +1,124 @@
package com.android.trisolarisserver.controller
import com.android.trisolarisserver.controller.dto.AmenityResponse
import com.android.trisolarisserver.controller.dto.AmenityUpsertRequest
import com.android.trisolarisserver.models.room.RoomAmenity
import com.android.trisolarisserver.repo.AppUserRepo
import com.android.trisolarisserver.repo.RoomAmenityRepo
import com.android.trisolarisserver.repo.RoomTypeRepo
import com.android.trisolarisserver.security.MyPrincipal
import org.springframework.http.HttpStatus
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.server.ResponseStatusException
import java.util.UUID
@RestController
@RequestMapping("/amenities")
class RoomAmenities(
private val roomAmenityRepo: RoomAmenityRepo,
private val roomTypeRepo: RoomTypeRepo,
private val appUserRepo: AppUserRepo
) {
@GetMapping
fun listAmenities(
@AuthenticationPrincipal principal: MyPrincipal?
): List<AmenityResponse> {
requirePrincipal(principal)
return roomAmenityRepo.findAllByOrderByName().map { it.toResponse() }
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
fun createAmenity(
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: AmenityUpsertRequest
): AmenityResponse {
requireSuperAdmin(principal)
if (roomAmenityRepo.existsByName(request.name)) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Amenity already exists")
}
val amenity = RoomAmenity(
name = request.name,
category = request.category,
iconKey = request.iconKey
)
return roomAmenityRepo.save(amenity).toResponse()
}
@PutMapping("/{amenityId}")
fun updateAmenity(
@PathVariable amenityId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: AmenityUpsertRequest
): AmenityResponse {
requireSuperAdmin(principal)
val amenity = roomAmenityRepo.findById(amenityId).orElse(null)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Amenity not found")
if (roomAmenityRepo.existsByNameAndIdNot(request.name, amenityId)) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Amenity already exists")
}
amenity.name = request.name
amenity.category = request.category ?: amenity.category
amenity.iconKey = request.iconKey ?: amenity.iconKey
return roomAmenityRepo.save(amenity).toResponse()
}
@DeleteMapping("/{amenityId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun deleteAmenity(
@PathVariable amenityId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
) {
requireSuperAdmin(principal)
val amenity = roomAmenityRepo.findById(amenityId).orElse(null)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Amenity not found")
if (roomTypeRepo.existsByAmenitiesId(amenityId)) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Amenity is used by room types")
}
roomAmenityRepo.delete(amenity)
}
private fun requirePrincipal(principal: MyPrincipal?) {
if (principal == null) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
}
}
private fun requireSuperAdmin(principal: MyPrincipal?) {
if (principal == null) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
}
val user = appUserRepo.findById(principal.userId).orElseThrow {
ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found")
}
if (!user.superAdmin) {
throw ResponseStatusException(HttpStatus.FORBIDDEN, "Super admin only")
}
}
}
private fun RoomAmenity.toResponse(): AmenityResponse {
val id = id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Amenity id missing")
return AmenityResponse(
id = id,
name = name,
category = category,
iconKey = iconKey
)
}

View File

@@ -0,0 +1,73 @@
package com.android.trisolarisserver.controller
import com.android.trisolarisserver.component.PropertyAccess
import com.android.trisolarisserver.controller.dto.ActiveRoomStayResponse
import com.android.trisolarisserver.models.property.Role
import com.android.trisolarisserver.repo.PropertyUserRepo
import com.android.trisolarisserver.repo.RoomStayRepo
import com.android.trisolarisserver.security.MyPrincipal
import org.springframework.http.HttpStatus
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.server.ResponseStatusException
import java.util.UUID
@RestController
class RoomStays(
private val propertyAccess: PropertyAccess,
private val propertyUserRepo: PropertyUserRepo,
private val roomStayRepo: RoomStayRepo
) {
@GetMapping("/properties/{propertyId}/room-stays/active")
fun listActiveRoomStays(
@PathVariable propertyId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
): List<ActiveRoomStayResponse> {
if (principal == null) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
}
propertyAccess.requireMember(propertyId, principal.userId)
val roles = propertyUserRepo.findRolesByPropertyAndUser(propertyId, principal.userId)
if (isAgentOnly(roles)) {
throw ResponseStatusException(HttpStatus.FORBIDDEN, "Agents cannot view active stays")
}
return roomStayRepo.findActiveByPropertyIdWithDetails(propertyId).map { stay ->
val booking = stay.booking
val guest = booking.primaryGuest
val room = stay.room
val roomType = room.roomType
ActiveRoomStayResponse(
roomStayId = stay.id!!,
bookingId = booking.id!!,
guestId = guest?.id,
guestName = guest?.name,
guestPhone = guest?.phoneE164,
roomId = room.id!!,
roomNumber = room.roomNumber,
roomTypeName = roomType.name,
fromAt = stay.fromAt.toString(),
checkinAt = booking.checkinAt?.toString(),
expectedCheckoutAt = booking.expectedCheckoutAt?.toString()
)
}
}
private fun isAgentOnly(roles: Set<Role>): Boolean {
if (!roles.contains(Role.AGENT)) return false
val privileged = setOf(
Role.ADMIN,
Role.MANAGER,
Role.STAFF,
Role.HOUSEKEEPING,
Role.FINANCE,
Role.SUPERVISOR,
Role.GUIDE
)
return roles.none { it in privileged }
}
}

View File

@@ -4,9 +4,11 @@ import com.android.trisolarisserver.component.PropertyAccess
import com.android.trisolarisserver.controller.dto.RoomTypeResponse import com.android.trisolarisserver.controller.dto.RoomTypeResponse
import com.android.trisolarisserver.controller.dto.RoomTypeUpsertRequest import com.android.trisolarisserver.controller.dto.RoomTypeUpsertRequest
import com.android.trisolarisserver.repo.PropertyRepo import com.android.trisolarisserver.repo.PropertyRepo
import com.android.trisolarisserver.repo.RoomAmenityRepo
import com.android.trisolarisserver.repo.RoomRepo import com.android.trisolarisserver.repo.RoomRepo
import com.android.trisolarisserver.repo.RoomTypeRepo import com.android.trisolarisserver.repo.RoomTypeRepo
import com.android.trisolarisserver.models.property.Role import com.android.trisolarisserver.models.property.Role
import com.android.trisolarisserver.models.room.RoomAmenity
import com.android.trisolarisserver.models.room.RoomType import com.android.trisolarisserver.models.room.RoomType
import com.android.trisolarisserver.security.MyPrincipal import com.android.trisolarisserver.security.MyPrincipal
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
@@ -28,6 +30,7 @@ import java.util.UUID
class RoomTypes( class RoomTypes(
private val propertyAccess: PropertyAccess, private val propertyAccess: PropertyAccess,
private val roomTypeRepo: RoomTypeRepo, private val roomTypeRepo: RoomTypeRepo,
private val roomAmenityRepo: RoomAmenityRepo,
private val roomRepo: RoomRepo, private val roomRepo: RoomRepo,
private val propertyRepo: PropertyRepo private val propertyRepo: PropertyRepo
) { ) {
@@ -66,11 +69,27 @@ class RoomTypes(
name = request.name, name = request.name,
baseOccupancy = request.baseOccupancy ?: 2, baseOccupancy = request.baseOccupancy ?: 2,
maxOccupancy = request.maxOccupancy ?: 3, maxOccupancy = request.maxOccupancy ?: 3,
sqFeet = request.sqFeet,
bathroomSqFeet = request.bathroomSqFeet,
otaAliases = request.otaAliases?.toMutableSet() ?: mutableSetOf() otaAliases = request.otaAliases?.toMutableSet() ?: mutableSetOf()
) )
if (request.amenityIds != null) {
roomType.amenities = resolveAmenities(request.amenityIds)
}
return roomTypeRepo.save(roomType).toResponse() return roomTypeRepo.save(roomType).toResponse()
} }
private fun resolveAmenities(ids: Set<UUID>): MutableSet<RoomAmenity> {
if (ids.isEmpty()) {
return mutableSetOf()
}
val amenities = roomAmenityRepo.findByIdIn(ids)
if (amenities.size != ids.size) {
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Amenity not found")
}
return amenities.toMutableSet()
}
@PutMapping("/{roomTypeId}") @PutMapping("/{roomTypeId}")
fun updateRoomType( fun updateRoomType(
@PathVariable propertyId: UUID, @PathVariable propertyId: UUID,
@@ -93,9 +112,14 @@ class RoomTypes(
roomType.name = request.name roomType.name = request.name
roomType.baseOccupancy = request.baseOccupancy ?: roomType.baseOccupancy roomType.baseOccupancy = request.baseOccupancy ?: roomType.baseOccupancy
roomType.maxOccupancy = request.maxOccupancy ?: roomType.maxOccupancy roomType.maxOccupancy = request.maxOccupancy ?: roomType.maxOccupancy
roomType.sqFeet = request.sqFeet ?: roomType.sqFeet
roomType.bathroomSqFeet = request.bathroomSqFeet ?: roomType.bathroomSqFeet
if (request.otaAliases != null) { if (request.otaAliases != null) {
roomType.otaAliases = request.otaAliases.toMutableSet() roomType.otaAliases = request.otaAliases.toMutableSet()
} }
if (request.amenityIds != null) {
roomType.amenities = resolveAmenities(request.amenityIds)
}
return roomTypeRepo.save(roomType).toResponse() return roomTypeRepo.save(roomType).toResponse()
} }
@@ -136,6 +160,19 @@ private fun RoomType.toResponse(): RoomTypeResponse {
name = name, name = name,
baseOccupancy = baseOccupancy, baseOccupancy = baseOccupancy,
maxOccupancy = maxOccupancy, maxOccupancy = maxOccupancy,
otaAliases = otaAliases.toSet() sqFeet = sqFeet,
bathroomSqFeet = bathroomSqFeet,
otaAliases = otaAliases.toSet(),
amenities = amenities.map { it.toResponse() }.toSet()
)
}
private fun RoomAmenity.toResponse(): com.android.trisolarisserver.controller.dto.AmenityResponse {
val id = id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Amenity id missing")
return com.android.trisolarisserver.controller.dto.AmenityResponse(
id = id,
name = name,
category = category,
iconKey = iconKey
) )
} }

View File

@@ -176,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,
@@ -190,9 +189,19 @@ class Rooms(
notes = request.notes notes = request.notes
) )
val saved = 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) roomBoardEvents.emit(propertyId)
return saved return response
} }
@PutMapping("/{roomId}") @PutMapping("/{roomId}")
@@ -212,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
@@ -223,9 +231,19 @@ class Rooms(
room.maintenance = request.maintenance room.maintenance = request.maintenance
room.notes = request.notes room.notes = request.notes
val saved = 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) roomBoardEvents.emit(propertyId)
return saved return response
} }
private fun requirePrincipal(principal: MyPrincipal?) { private fun requirePrincipal(principal: MyPrincipal?) {
@@ -247,16 +265,23 @@ class Rooms(
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid date format") throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid date format")
} }
} }
private fun resolveRoomType(propertyId: UUID, request: RoomUpsertRequest): com.android.trisolarisserver.models.room.RoomType {
val code = request.roomTypeCode.trim()
if (code.isBlank()) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "roomTypeCode required")
}
return roomTypeRepo.findByPropertyIdAndCodeIgnoreCase(propertyId, code)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room type not found")
}
} }
private fun Room.toRoomResponse(): RoomResponse { private fun Room.toRoomResponse(): RoomResponse {
val roomId = id ?: throw IllegalStateException("Room id is null") val roomId = id ?: throw IllegalStateException("Room id is null")
val roomTypeId = roomType.id ?: throw IllegalStateException("Room type id is null")
return RoomResponse( return RoomResponse(
id = roomId, id = roomId,
roomNumber = roomNumber, roomNumber = roomNumber,
floor = floor, floor = floor,
roomTypeId = roomTypeId,
roomTypeName = roomType.name, roomTypeName = roomType.name,
hasNfc = hasNfc, hasNfc = hasNfc,
active = active, active = active,

View File

@@ -31,10 +31,10 @@ class TransportModes(
val property = propertyRepo.findById(propertyId).orElseThrow { val property = propertyRepo.findById(propertyId).orElseThrow {
ResponseStatusException(org.springframework.http.HttpStatus.NOT_FOUND, "Property not found") ResponseStatusException(org.springframework.http.HttpStatus.NOT_FOUND, "Property not found")
} }
val allowed = when { val allowed = if (property.allowedTransportModes.isNotEmpty()) {
property.allowedTransportModes.isNotEmpty() -> property.allowedTransportModes property.allowedTransportModes
property.org.allowedTransportModes.isNotEmpty() -> property.org.allowedTransportModes } else {
else -> TransportMode.entries.toSet() TransportMode.entries.toSet()
} }
return TransportMode.entries.map { mode -> return TransportMode.entries.map { mode ->
TransportModeStatusResponse( TransportModeStatusResponse(

View File

@@ -10,7 +10,6 @@ data class GuestRatingCreateRequest(
data class GuestRatingResponse( data class GuestRatingResponse(
val id: UUID, val id: UUID,
val orgId: UUID,
val propertyId: UUID, val propertyId: UUID,
val guestId: UUID, val guestId: UUID,
val bookingId: UUID, val bookingId: UUID,

View File

@@ -2,19 +2,6 @@ package com.android.trisolarisserver.controller.dto
import java.util.UUID import java.util.UUID
data class OrgCreateRequest(
val name: String,
val emailAliases: Set<String>? = null,
val allowedTransportModes: Set<String>? = null
)
data class OrgResponse(
val id: UUID,
val name: String,
val emailAliases: Set<String>,
val allowedTransportModes: Set<String>
)
data class PropertyCreateRequest( data class PropertyCreateRequest(
val code: String, val code: String,
val name: String, val name: String,
@@ -41,7 +28,6 @@ data class PropertyUpdateRequest(
data class PropertyResponse( data class PropertyResponse(
val id: UUID, val id: UUID,
val orgId: UUID,
val code: String, val code: String,
val name: String, val name: String,
val addressText: String?, val addressText: String?,
@@ -55,7 +41,6 @@ data class PropertyResponse(
data class GuestResponse( data class GuestResponse(
val id: UUID, val id: UUID,
val orgId: UUID,
val name: String?, val name: String?,
val phoneE164: String?, val phoneE164: String?,
val nationality: String?, val nationality: String?,
@@ -75,11 +60,11 @@ data class TransportModeStatusResponse(
data class UserResponse( data class UserResponse(
val id: UUID, val id: UUID,
val orgId: UUID,
val firebaseUid: String?, val firebaseUid: String?,
val phoneE164: String?, val phoneE164: String?,
val name: String?, val name: String?,
val disabled: Boolean val disabled: Boolean,
val superAdmin: Boolean
) )
data class PropertyUserRoleRequest( data class PropertyUserRoleRequest(

View File

@@ -6,7 +6,6 @@ data class RoomResponse(
val id: UUID, val id: UUID,
val roomNumber: Int, val roomNumber: Int,
val floor: Int?, val floor: Int?,
val roomTypeId: UUID,
val roomTypeName: String, val roomTypeName: String,
val hasNfc: Boolean, val hasNfc: Boolean,
val active: Boolean, val active: Boolean,
@@ -52,7 +51,7 @@ enum class RoomBoardStatus {
data class RoomUpsertRequest( data class RoomUpsertRequest(
val roomNumber: Int, val roomNumber: Int,
val floor: Int?, val floor: Int?,
val roomTypeId: UUID, val roomTypeCode: String,
val hasNfc: Boolean, val hasNfc: Boolean,
val active: Boolean, val active: Boolean,
val maintenance: Boolean, val maintenance: Boolean,

View File

@@ -0,0 +1,17 @@
package com.android.trisolarisserver.controller.dto
import java.util.UUID
data class ActiveRoomStayResponse(
val roomStayId: UUID,
val bookingId: UUID,
val guestId: UUID?,
val guestName: String?,
val guestPhone: String?,
val roomId: UUID,
val roomNumber: Int,
val roomTypeName: String,
val fromAt: String,
val checkinAt: String?,
val expectedCheckoutAt: String?
)

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,6 @@
package com.android.trisolarisserver.models.booking package com.android.trisolarisserver.models.booking
import com.android.trisolarisserver.models.property.AppUser import com.android.trisolarisserver.models.property.AppUser
import com.android.trisolarisserver.models.property.Organization
import com.android.trisolarisserver.models.property.Property import com.android.trisolarisserver.models.property.Property
import jakarta.persistence.Column import jakarta.persistence.Column
import jakarta.persistence.Entity import jakarta.persistence.Entity
@@ -30,10 +29,6 @@ class GuestRating(
@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,
@ManyToOne(fetch = FetchType.LAZY, optional = false) @ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "property_id", nullable = false) @JoinColumn(name = "property_id", nullable = false)
var property: Property, var property: Property,

View File

@@ -1,6 +1,6 @@
package com.android.trisolarisserver.models.booking package com.android.trisolarisserver.models.booking
import com.android.trisolarisserver.models.property.Organization import com.android.trisolarisserver.models.property.Property
import jakarta.persistence.* import jakarta.persistence.*
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.util.UUID import java.util.UUID
@@ -8,7 +8,7 @@ import java.util.UUID
@Entity @Entity
@Table( @Table(
name = "guest_vehicle", name = "guest_vehicle",
uniqueConstraints = [UniqueConstraint(columnNames = ["org_id", "vehicle_number"])] uniqueConstraints = [UniqueConstraint(columnNames = ["property_id", "vehicle_number"])]
) )
class GuestVehicle( class GuestVehicle(
@Id @Id
@@ -17,8 +17,8 @@ class GuestVehicle(
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,
@ManyToOne(fetch = FetchType.LAZY, optional = false) @ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "guest_id", nullable = false) @JoinColumn(name = "guest_id", nullable = false)

View File

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

View File

@@ -1,37 +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
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(
name = "org_email_alias",
joinColumns = [JoinColumn(name = "org_id")]
)
@Column(name = "email", nullable = false)
var emailAliases: MutableSet<String> = mutableSetOf()
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(
name = "org_transport_mode",
joinColumns = [JoinColumn(name = "org_id")]
)
@Column(name = "mode", nullable = false)
@Enumerated(EnumType.STRING)
var allowedTransportModes: MutableSet<com.android.trisolarisserver.models.booking.TransportMode> =
mutableSetOf()
@Column(name = "created_at", nullable = false, columnDefinition = "timestamptz")
val createdAt: OffsetDateTime = OffsetDateTime.now()
}

View File

@@ -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"

View File

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

View File

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

View File

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

View File

@@ -5,7 +5,7 @@ import org.springframework.data.jpa.repository.JpaRepository
import java.util.UUID import java.util.UUID
interface GuestVehicleRepo : JpaRepository<GuestVehicle, UUID> { interface GuestVehicleRepo : JpaRepository<GuestVehicle, UUID> {
fun findByOrgIdAndVehicleNumberIgnoreCase(orgId: UUID, vehicleNumber: String): GuestVehicle? fun findByPropertyIdAndVehicleNumberIgnoreCase(propertyId: UUID, vehicleNumber: String): GuestVehicle?
fun findByGuestIdIn(guestIds: List<UUID>): List<GuestVehicle> fun findByGuestIdIn(guestIds: List<UUID>): List<GuestVehicle>
fun existsByOrgIdAndVehicleNumberIgnoreCase(orgId: UUID, vehicleNumber: String): Boolean fun existsByPropertyIdAndVehicleNumberIgnoreCase(propertyId: UUID, vehicleNumber: String): Boolean
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -62,4 +62,17 @@ interface RoomStayRepo : JpaRepository<RoomStay, UUID> {
@Param("fromAt") fromAt: java.time.OffsetDateTime, @Param("fromAt") fromAt: java.time.OffsetDateTime,
@Param("toAt") toAt: java.time.OffsetDateTime @Param("toAt") toAt: java.time.OffsetDateTime
): Boolean ): Boolean
@Query("""
select rs
from RoomStay rs
join fetch rs.room r
join fetch r.roomType rt
join fetch rs.booking b
left join fetch b.primaryGuest g
where rs.property.id = :propertyId
and rs.toAt is null
order by r.roomNumber
""")
fun findActiveByPropertyIdWithDetails(@Param("propertyId") propertyId: UUID): List<RoomStay>
} }

View File

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

View File

@@ -5,6 +5,7 @@ import com.google.firebase.auth.FirebaseAuth
import jakarta.servlet.FilterChain import jakarta.servlet.FilterChain
import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse import jakarta.servlet.http.HttpServletResponse
import org.slf4j.LoggerFactory
import org.springframework.http.HttpHeaders import org.springframework.http.HttpHeaders
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.context.SecurityContextHolder import org.springframework.security.core.context.SecurityContextHolder
@@ -17,10 +18,11 @@ 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 { override fun shouldNotFilter(request: HttpServletRequest): Boolean {
val path = request.requestURI val path = request.requestURI
return path == "/" || path == "/health" return path == "/" || path == "/health" || path.startsWith("/auth/")
} }
override fun doFilterInternal( override fun doFilterInternal(
@@ -30,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
} }
@@ -39,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"),
@@ -48,6 +52,7 @@ class FirebaseAuthFilter(
SecurityContextHolder.getContext().authentication = auth SecurityContextHolder.getContext().authentication = auth
filterChain.doFilter(request, response) filterChain.doFilter(request, response)
} catch (ex: Exception) { } catch (ex: Exception) {
logger.debug("Auth failed for {}: {}", request.requestURI, ex.message)
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid token") response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid token")
} }
} }

View File

@@ -2,14 +2,22 @@ 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 {
@@ -17,10 +25,38 @@ class SecurityConfig(
.csrf { it.disable() } .csrf { it.disable() }
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
.authorizeHttpRequests { .authorizeHttpRequests {
it.requestMatchers("/", "/health").permitAll() it.requestMatchers("/", "/health", "/auth/**").permitAll()
it.anyRequest().authenticated() it.anyRequest().authenticated()
} }
.exceptionHandling {
it.authenticationEntryPoint { request, response, _ ->
writeError(response, request, HttpStatus.UNAUTHORIZED, "Unauthorized")
}
it.accessDeniedHandler { request, response, _ ->
writeError(response, request, HttpStatus.FORBIDDEN, "Forbidden")
}
}
.httpBasic { it.disable() }
.formLogin { it.disable() }
.addFilterBefore(firebaseAuthFilter, UsernamePasswordAuthenticationFilter::class.java) .addFilterBefore(firebaseAuthFilter, UsernamePasswordAuthenticationFilter::class.java)
return http.build() return http.build()
} }
private fun writeError(
response: HttpServletResponse,
request: HttpServletRequest,
status: HttpStatus,
message: String
) {
if (response.isCommitted) return
response.status = status.value()
response.contentType = "application/json"
val body = mapOf(
"status" to status.value(),
"error" to status.reasonPhrase,
"message" to message,
"path" to request.requestURI
)
response.writer.use { it.write(objectMapper.writeValueAsString(body)) }
}
} }

View File

@@ -157,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) }
) )
@@ -281,10 +281,6 @@ class EmailIngestionService(
if (propertyEmails.isNotEmpty() && recipients.any { it.lowercase() in propertyEmails }) { if (propertyEmails.isNotEmpty() && recipients.any { it.lowercase() in propertyEmails }) {
return@filter true return@filter true
} }
val orgEmails = property.org.emailAliases.map { it.lowercase() }.toSet()
if (orgEmails.isNotEmpty() && recipients.any { it.lowercase() in orgEmails }) {
return@filter true
}
} }
val aliases = mutableSetOf<String>() val aliases = mutableSetOf<String>()
aliases.add(property.name) aliases.add(property.name)

View File

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