Remove org model; make AppUser global with super admin
All checks were successful
build-and-deploy / build-deploy (push) Successful in 27s

This commit is contained in:
androidlover5842
2026-01-26 22:33:59 +05:30
parent bf87d329d4
commit 1400451bfe
26 changed files with 101 additions and 362 deletions

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

@@ -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,14 +9,19 @@ 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) {
val user = appUserRepo.findById(userId).orElse(null)
if (user?.superAdmin == true) return
if (!repo.existsByIdPropertyIdAndIdUserId(propertyId, userId)) if (!repo.existsByIdPropertyIdAndIdUserId(propertyId, userId))
throw AccessDeniedException("No access to property") throw AccessDeniedException("No access to property")
} }
fun requireAnyRole(propertyId: UUID, userId: UUID, vararg roles: Role) { fun requireAnyRole(propertyId: UUID, userId: UUID, vararg roles: Role) {
val user = appUserRepo.findById(userId).orElse(null)
if (user?.superAdmin == true) return
if (!repo.hasAnyRole(propertyId, userId, roles.toSet())) if (!repo.hasAnyRole(propertyId, userId, roles.toSet()))
throw AccessDeniedException("Missing role") throw AccessDeniedException("Missing role")
} }

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

@@ -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")
@@ -153,9 +141,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 +186,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 +224,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 +235,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 +248,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

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

@@ -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,33 +0,0 @@
package com.android.trisolarisserver.models.property
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 = "pending_user",
uniqueConstraints = [UniqueConstraint(columnNames = ["firebase_uid"])]
)
class PendingUser(
@Id
@GeneratedValue
@Column(columnDefinition = "uuid")
val id: UUID? = null,
@Column(name = "firebase_uid", nullable = false)
var firebaseUid: String,
@Column(name = "phone_e164")
var phoneE164: String? = null,
var name: String? = null,
@Column(name = "created_at", nullable = false, columnDefinition = "timestamptz")
val createdAt: OffsetDateTime = OffsetDateTime.now()
)

View File

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

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

@@ -1,9 +0,0 @@
package com.android.trisolarisserver.repo
import com.android.trisolarisserver.models.property.PendingUser
import org.springframework.data.jpa.repository.JpaRepository
import java.util.UUID
interface PendingUserRepo : JpaRepository<PendingUser, UUID> {
fun findByFirebaseUid(firebaseUid: String): PendingUser?
}

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

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