Compare commits

...

48 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
45 changed files with 739 additions and 363 deletions

View File

@@ -12,11 +12,11 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Set up JDK 19
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "19"
java-version: "21"
cache: gradle
- name: Build (skip tests)
@@ -27,6 +27,6 @@ jobs:
- name: Deploy jar and restart
run: |
set -e
sudo mkdir -p /opt/deploy/TrisolarisServer/build/libs
sudo cp -f build/libs/*.jar /opt/deploy/TrisolarisServer/build/libs/
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 change = close old RoomStay + open new one.
- 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
- Use Kotlin only; no microservices.
@@ -46,19 +46,18 @@ Security/Auth
- /auth/verify and /auth/me.
Domain entities
- Organization: name, emailAliases, 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.
- Room: roomNumber, floor, hasNfc, active, maintenance, notes.
- Booking: status, expected check-in/out, emailAuditPdfUrl, transportMode, transportVehicleNumber.
- Guest (org-scoped).
- Guest (property-scoped).
- RoomStay.
- RoomStayChange (idempotent room move).
- IssuedCard (cardId, cardIndex, issuedAt, expiresAt, issuedBy, revokedAt).
- PropertyCardCounter (per-property cardIndex counter).
- GuestDocument (files + AI-extracted json).
- GuestVehicle (org-scoped vehicle numbers).
- GuestVehicle (property-scoped vehicle numbers).
- InboundEmail (audit PDF + raw EML, extracted json, status).
- RoomImage (original + thumbnail).
@@ -68,14 +67,10 @@ Auth
- /auth/verify
- /auth/me
Organizations / Properties / Users
- POST /orgs
- GET /orgs/{orgId}
- POST /orgs/{orgId}/properties
- GET /orgs/{orgId}/properties
Properties / Users
- POST /properties (creator becomes ADMIN on that property)
- GET /properties (super admin gets all; others get memberships)
- PUT /properties/{propertyId}
- GET /orgs/{orgId}/users
- POST /orgs/{orgId}/users (removed; users created by app)
- GET /properties/{propertyId}/users
- PUT /properties/{propertyId}/users/{userId}/roles
- DELETE /properties/{propertyId}/users/{userId} (ADMIN only)
@@ -93,9 +88,8 @@ Room types
- PUT /properties/{propertyId}/room-types/{roomTypeId}
- DELETE /properties/{propertyId}/room-types/{roomTypeId}
Properties / Orgs
Properties
- Property create/update accepts addressText, otaAliases, emailAddresses, allowedTransportModes.
- Org create/get returns emailAliases + allowedTransportModes.
Booking flow
- /properties/{propertyId}/bookings/{bookingId}/check-in (creates RoomStay rows)
@@ -126,7 +120,7 @@ Room images
- Thumbnails generated (320px).
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
- IMAP poller (1 min) with enable flag.
@@ -153,5 +147,6 @@ Config
Notes / constraints
- Users are created by app; API only manages roles.
- Super admin can create properties and assign users to properties.
- Admin can assign ADMIN/MANAGER/STAFF/AGENT; Manager can assign STAFF/AGENT.
- Agents can only see free rooms.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -51,8 +51,8 @@ class GuestRatings(
val guest = guestRepo.findById(guestId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Guest not found")
}
if (guest.org.id != property.org.id) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Guest not in property org")
if (guest.property.id != property.id) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Guest not in property")
}
val booking = bookingRepo.findById(request.bookingId).orElseThrow {
@@ -70,7 +70,6 @@ class GuestRatings(
val score = parseScore(request.score)
val rating = GuestRating(
org = property.org,
property = property,
guest = guest,
booking = booking,
@@ -97,8 +96,8 @@ class GuestRatings(
val guest = guestRepo.findById(guestId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Guest not found")
}
if (guest.org.id != property.org.id) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Guest not in property org")
if (guest.property.id != property.id) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Guest not in property")
}
return guestRatingRepo.findByGuestIdOrderByCreatedAtDesc(guestId).map { it.toResponse() }
@@ -117,7 +116,6 @@ class GuestRatings(
private fun GuestRating.toResponse(): GuestRatingResponse {
return GuestRatingResponse(
id = id!!,
orgId = org.id!!,
propertyId = property.id!!,
guestId = guest.id!!,
bookingId = booking.id!!,

View File

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

View File

@@ -7,11 +7,11 @@ import org.springframework.web.bind.annotation.RestController
class Health {
@GetMapping("/health")
fun health(): Map<String, String> {
return mapOf("status" to "ok Testing Health..")
return mapOf("status" to "ok", "build" to "2026-01-26-authfix")
}
@GetMapping("/")
fun root(): Map<String, String> {
return mapOf("status" to "Hello World!")
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.PropertyUserResponse
import com.android.trisolarisserver.controller.dto.PropertyUserRoleRequest
import com.android.trisolarisserver.controller.dto.UserResponse
import com.android.trisolarisserver.repo.AppUserRepo
import com.android.trisolarisserver.repo.OrganizationRepo
import com.android.trisolarisserver.repo.PropertyRepo
import com.android.trisolarisserver.repo.PropertyUserRepo
import com.android.trisolarisserver.models.property.Property
@@ -34,33 +32,22 @@ import java.util.UUID
class Properties(
private val propertyAccess: PropertyAccess,
private val propertyRepo: PropertyRepo,
private val orgRepo: OrganizationRepo,
private val propertyUserRepo: PropertyUserRepo,
private val appUserRepo: AppUserRepo
) {
@PostMapping("/orgs/{orgId}/properties")
@PostMapping("/properties")
@ResponseStatus(HttpStatus.CREATED)
fun createProperty(
@PathVariable orgId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: PropertyCreateRequest
): PropertyResponse {
val user = requireUser(principal)
if (user.org.id != orgId) {
throw ResponseStatusException(HttpStatus.FORBIDDEN, "No access to org")
}
requireOrgRole(orgId, user.id!!, Role.ADMIN)
if (propertyRepo.existsByOrgIdAndCode(orgId, request.code)) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Property code already exists for org")
if (propertyRepo.existsByCode(request.code)) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Property code already exists")
}
val org = orgRepo.findById(orgId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Org not found")
}
val property = Property(
org = org,
code = request.code,
name = request.name,
addressText = request.addressText,
@@ -72,33 +59,34 @@ class Properties(
allowedTransportModes = request.allowedTransportModes?.let { parseTransportModes(it) } ?: mutableSetOf()
)
val saved = propertyRepo.save(property)
val creatorId = user.id ?: throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "User id missing")
val propertyUserId = PropertyUserId(propertyId = saved.id!!, userId = creatorId)
if (!propertyUserRepo.existsById(propertyUserId)) {
propertyUserRepo.save(
PropertyUser(
id = propertyUserId,
property = saved,
user = user,
roles = mutableSetOf(Role.ADMIN)
)
)
}
return saved.toResponse()
}
@GetMapping("/orgs/{orgId}/properties")
@GetMapping("/properties")
fun listProperties(
@PathVariable orgId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
): List<PropertyResponse> {
val user = requireUser(principal)
if (user.org.id != orgId) {
throw ResponseStatusException(HttpStatus.FORBIDDEN, "No access to org")
return if (user.superAdmin) {
propertyRepo.findAll().map { it.toResponse() }
} else {
val propertyIds = propertyUserRepo.findByIdUserId(user.id!!).map { it.id.propertyId!! }
propertyRepo.findAllById(propertyIds).map { it.toResponse() }
}
val propertyIds = propertyUserRepo.findPropertyIdsByOrgAndUser(orgId, user.id!!)
return propertyRepo.findAllById(propertyIds).map { it.toResponse() }
}
@GetMapping("/orgs/{orgId}/users")
fun listUsers(
@PathVariable orgId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
): List<UserResponse> {
val user = requireUser(principal)
if (user.org.id != orgId) {
throw ResponseStatusException(HttpStatus.FORBIDDEN, "No access to org")
}
requireOrgRole(orgId, user.id!!, Role.ADMIN, Role.MANAGER)
return appUserRepo.findByOrgId(orgId).map { it.toUserResponse() }
}
@GetMapping("/properties/{propertyId}/users")
@@ -129,8 +117,10 @@ class Properties(
requirePrincipal(principal)
propertyAccess.requireMember(propertyId, principal!!.userId)
val actorUser = appUserRepo.findById(principal.userId).orElse(null)
val actorRoles = propertyUserRepo.findRolesByPropertyAndUser(propertyId, principal.userId)
val allowedRoles = when {
actorUser?.superAdmin == true -> setOf(Role.ADMIN, Role.MANAGER, Role.STAFF, Role.AGENT)
actorRoles.contains(Role.ADMIN) -> setOf(Role.ADMIN, Role.MANAGER, Role.STAFF, Role.AGENT)
actorRoles.contains(Role.MANAGER) -> setOf(Role.STAFF, Role.AGENT)
else -> emptySet()
@@ -153,9 +143,6 @@ class Properties(
val targetUser = appUserRepo.findById(userId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "User not found")
}
if (targetUser.org.id != property.org.id) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "User not in property org")
}
val propertyUser = PropertyUser(
id = PropertyUserId(propertyId = propertyId, userId = userId),
@@ -201,8 +188,8 @@ class Properties(
val property = propertyRepo.findById(propertyId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
}
if (propertyRepo.existsByOrgIdAndCodeAndIdNot(property.org.id!!, request.code, propertyId)) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Property code already exists for org")
if (propertyRepo.existsByCodeAndIdNot(request.code, propertyId)) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Property code already exists")
}
property.code = request.code
@@ -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> {
return try {
modes.map { TransportMode.valueOf(it) }.toMutableSet()
@@ -256,10 +237,8 @@ class Properties(
private fun Property.toResponse(): PropertyResponse {
val id = id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Property id missing")
val orgId = org.id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Org id missing")
return PropertyResponse(
id = id,
orgId = orgId,
code = code,
name = name,
addressText = addressText,
@@ -271,16 +250,3 @@ private fun Property.toResponse(): PropertyResponse {
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.RoomTypeUpsertRequest
import com.android.trisolarisserver.repo.PropertyRepo
import com.android.trisolarisserver.repo.RoomAmenityRepo
import com.android.trisolarisserver.repo.RoomRepo
import com.android.trisolarisserver.repo.RoomTypeRepo
import com.android.trisolarisserver.models.property.Role
import com.android.trisolarisserver.models.room.RoomAmenity
import com.android.trisolarisserver.models.room.RoomType
import com.android.trisolarisserver.security.MyPrincipal
import org.springframework.http.HttpStatus
@@ -28,6 +30,7 @@ import java.util.UUID
class RoomTypes(
private val propertyAccess: PropertyAccess,
private val roomTypeRepo: RoomTypeRepo,
private val roomAmenityRepo: RoomAmenityRepo,
private val roomRepo: RoomRepo,
private val propertyRepo: PropertyRepo
) {
@@ -66,11 +69,27 @@ class RoomTypes(
name = request.name,
baseOccupancy = request.baseOccupancy ?: 2,
maxOccupancy = request.maxOccupancy ?: 3,
sqFeet = request.sqFeet,
bathroomSqFeet = request.bathroomSqFeet,
otaAliases = request.otaAliases?.toMutableSet() ?: mutableSetOf()
)
if (request.amenityIds != null) {
roomType.amenities = resolveAmenities(request.amenityIds)
}
return roomTypeRepo.save(roomType).toResponse()
}
private fun resolveAmenities(ids: Set<UUID>): MutableSet<RoomAmenity> {
if (ids.isEmpty()) {
return mutableSetOf()
}
val amenities = roomAmenityRepo.findByIdIn(ids)
if (amenities.size != ids.size) {
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Amenity not found")
}
return amenities.toMutableSet()
}
@PutMapping("/{roomTypeId}")
fun updateRoomType(
@PathVariable propertyId: UUID,
@@ -93,9 +112,14 @@ class RoomTypes(
roomType.name = request.name
roomType.baseOccupancy = request.baseOccupancy ?: roomType.baseOccupancy
roomType.maxOccupancy = request.maxOccupancy ?: roomType.maxOccupancy
roomType.sqFeet = request.sqFeet ?: roomType.sqFeet
roomType.bathroomSqFeet = request.bathroomSqFeet ?: roomType.bathroomSqFeet
if (request.otaAliases != null) {
roomType.otaAliases = request.otaAliases.toMutableSet()
}
if (request.amenityIds != null) {
roomType.amenities = resolveAmenities(request.amenityIds)
}
return roomTypeRepo.save(roomType).toResponse()
}
@@ -136,6 +160,19 @@ private fun RoomType.toResponse(): RoomTypeResponse {
name = name,
baseOccupancy = baseOccupancy,
maxOccupancy = maxOccupancy,
otaAliases = otaAliases.toSet()
sqFeet = sqFeet,
bathroomSqFeet = bathroomSqFeet,
otaAliases = otaAliases.toSet(),
amenities = amenities.map { it.toResponse() }.toSet()
)
}
private fun RoomAmenity.toResponse(): com.android.trisolarisserver.controller.dto.AmenityResponse {
val id = id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Amenity id missing")
return com.android.trisolarisserver.controller.dto.AmenityResponse(
id = id,
name = name,
category = category,
iconKey = iconKey
)
}

View File

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

View File

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

View File

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

View File

@@ -2,19 +2,6 @@ package com.android.trisolarisserver.controller.dto
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(
val code: String,
val name: String,
@@ -41,7 +28,6 @@ data class PropertyUpdateRequest(
data class PropertyResponse(
val id: UUID,
val orgId: UUID,
val code: String,
val name: String,
val addressText: String?,
@@ -55,7 +41,6 @@ data class PropertyResponse(
data class GuestResponse(
val id: UUID,
val orgId: UUID,
val name: String?,
val phoneE164: String?,
val nationality: String?,
@@ -75,11 +60,11 @@ data class TransportModeStatusResponse(
data class UserResponse(
val id: UUID,
val orgId: UUID,
val firebaseUid: String?,
val phoneE164: String?,
val name: String?,
val disabled: Boolean
val disabled: Boolean,
val superAdmin: Boolean
)
data class PropertyUserRoleRequest(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,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
import jakarta.persistence.*
import jakarta.persistence.Column
import jakarta.persistence.ElementCollection
import jakarta.persistence.Entity
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
import jakarta.persistence.FetchType
import jakarta.persistence.GeneratedValue
import jakarta.persistence.Id
import jakarta.persistence.JoinColumn
import jakarta.persistence.CollectionTable
import jakarta.persistence.Table
import jakarta.persistence.UniqueConstraint
import java.time.OffsetDateTime
import java.util.UUID
@Entity
@Table(
name = "property",
uniqueConstraints = [UniqueConstraint(columnNames = ["org_id", "code"])]
uniqueConstraints = [UniqueConstraint(columnNames = ["code"])]
)
class Property(
@Id
@@ -15,10 +26,6 @@ class Property(
@Column(columnDefinition = "uuid")
val id: UUID? = null,
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "org_id", nullable = false)
var org: Organization,
@Column(nullable = false)
var code: String, // "TRI-VNS"

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -157,11 +157,11 @@ class EmailIngestionService(
private fun resolveGuest(property: Property, extracted: Map<String, String>): Guest {
val phone = extracted["guestPhone"]?.takeIf { !it.contains("NONE", true) }?.trim()
if (!phone.isNullOrBlank()) {
val existing = guestRepo.findByOrgIdAndPhoneE164(property.org.id!!, phone)
val existing = guestRepo.findByPropertyIdAndPhoneE164(property.id!!, phone)
if (existing != null) return existing
}
val guest = Guest(
org = property.org,
property = property,
phoneE164 = phone,
name = extracted["guestName"]?.takeIf { !it.contains("NONE", true) }
)
@@ -281,10 +281,6 @@ class EmailIngestionService(
if (propertyEmails.isNotEmpty() && recipients.any { it.lowercase() in propertyEmails }) {
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>()
aliases.add(property.name)

View File

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