Add user search and property access code flow
All checks were successful
build-and-deploy / build-deploy (push) Successful in 36s
All checks were successful
build-and-deploy / build-deploy (push) Successful in 36s
This commit is contained in:
@@ -0,0 +1,38 @@
|
|||||||
|
package com.android.trisolarisserver.controller.dto.property
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
data class AppUserSummaryResponse(
|
||||||
|
val id: UUID,
|
||||||
|
val phoneE164: String?,
|
||||||
|
val name: String?,
|
||||||
|
val disabled: Boolean,
|
||||||
|
val superAdmin: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
data class PropertyUserDetailsResponse(
|
||||||
|
val userId: UUID,
|
||||||
|
val propertyId: UUID,
|
||||||
|
val roles: Set<String>,
|
||||||
|
val name: String?,
|
||||||
|
val phoneE164: String?,
|
||||||
|
val disabled: Boolean,
|
||||||
|
val superAdmin: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
data class PropertyAccessCodeCreateRequest(
|
||||||
|
val roles: Set<String>
|
||||||
|
)
|
||||||
|
|
||||||
|
data class PropertyAccessCodeResponse(
|
||||||
|
val propertyId: UUID,
|
||||||
|
val code: String,
|
||||||
|
val expiresAt: OffsetDateTime,
|
||||||
|
val roles: Set<String>
|
||||||
|
)
|
||||||
|
|
||||||
|
data class PropertyAccessCodeJoinRequest(
|
||||||
|
val propertyId: UUID,
|
||||||
|
val code: String
|
||||||
|
)
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package com.android.trisolarisserver.controller.property
|
||||||
|
|
||||||
|
import com.android.trisolarisserver.component.auth.PropertyAccess
|
||||||
|
import com.android.trisolarisserver.controller.common.requirePrincipal
|
||||||
|
import com.android.trisolarisserver.controller.dto.property.PropertyAccessCodeCreateRequest
|
||||||
|
import com.android.trisolarisserver.controller.dto.property.PropertyAccessCodeJoinRequest
|
||||||
|
import com.android.trisolarisserver.controller.dto.property.PropertyAccessCodeResponse
|
||||||
|
import com.android.trisolarisserver.controller.dto.property.PropertyUserResponse
|
||||||
|
import com.android.trisolarisserver.models.property.PropertyAccessCode
|
||||||
|
import com.android.trisolarisserver.models.property.PropertyUser
|
||||||
|
import com.android.trisolarisserver.models.property.PropertyUserId
|
||||||
|
import com.android.trisolarisserver.models.property.Role
|
||||||
|
import com.android.trisolarisserver.repo.property.AppUserRepo
|
||||||
|
import com.android.trisolarisserver.repo.property.PropertyAccessCodeRepo
|
||||||
|
import com.android.trisolarisserver.repo.property.PropertyRepo
|
||||||
|
import com.android.trisolarisserver.repo.property.PropertyUserRepo
|
||||||
|
import com.android.trisolarisserver.security.MyPrincipal
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
|
import org.springframework.web.bind.annotation.ResponseStatus
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import org.springframework.web.server.ResponseStatusException
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import java.security.SecureRandom
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
class PropertyAccessCodes(
|
||||||
|
private val propertyAccess: PropertyAccess,
|
||||||
|
private val propertyRepo: PropertyRepo,
|
||||||
|
private val propertyUserRepo: PropertyUserRepo,
|
||||||
|
private val accessCodeRepo: PropertyAccessCodeRepo,
|
||||||
|
private val appUserRepo: AppUserRepo
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val secureRandom = SecureRandom()
|
||||||
|
|
||||||
|
@PostMapping("/properties/{propertyId}/access-codes")
|
||||||
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
|
fun createAccessCode(
|
||||||
|
@PathVariable propertyId: UUID,
|
||||||
|
@AuthenticationPrincipal principal: MyPrincipal?,
|
||||||
|
@RequestBody request: PropertyAccessCodeCreateRequest
|
||||||
|
): PropertyAccessCodeResponse {
|
||||||
|
val resolved = requirePrincipal(principal)
|
||||||
|
propertyAccess.requireMember(propertyId, resolved.userId)
|
||||||
|
propertyAccess.requireAnyRole(propertyId, resolved.userId, Role.ADMIN)
|
||||||
|
|
||||||
|
val roles = parseRoles(request.roles)
|
||||||
|
if (roles.contains(Role.ADMIN)) {
|
||||||
|
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "ADMIN cannot be invited by code")
|
||||||
|
}
|
||||||
|
if (roles.isEmpty()) {
|
||||||
|
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "At least one role is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
val property = propertyRepo.findById(propertyId).orElseThrow {
|
||||||
|
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
|
||||||
|
}
|
||||||
|
val actor = appUserRepo.findById(resolved.userId).orElseThrow {
|
||||||
|
ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found")
|
||||||
|
}
|
||||||
|
val now = OffsetDateTime.now()
|
||||||
|
val expiresAt = now.plusMinutes(1)
|
||||||
|
val code = generateCode(propertyId, now)
|
||||||
|
|
||||||
|
val accessCode = PropertyAccessCode(
|
||||||
|
property = property,
|
||||||
|
createdBy = actor,
|
||||||
|
code = code,
|
||||||
|
expiresAt = expiresAt,
|
||||||
|
roles = roles.toMutableSet()
|
||||||
|
)
|
||||||
|
accessCodeRepo.save(accessCode)
|
||||||
|
|
||||||
|
return PropertyAccessCodeResponse(
|
||||||
|
propertyId = propertyId,
|
||||||
|
code = code,
|
||||||
|
expiresAt = expiresAt,
|
||||||
|
roles = roles.map { it.name }.toSet()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/properties/access-codes/join")
|
||||||
|
@Transactional
|
||||||
|
fun joinWithAccessCode(
|
||||||
|
@AuthenticationPrincipal principal: MyPrincipal?,
|
||||||
|
@RequestBody request: PropertyAccessCodeJoinRequest
|
||||||
|
): PropertyUserResponse {
|
||||||
|
val resolved = requirePrincipal(principal)
|
||||||
|
val code = request.code.trim()
|
||||||
|
if (code.length != 6 || !code.all { it.isDigit() }) {
|
||||||
|
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Invalid code")
|
||||||
|
}
|
||||||
|
val now = OffsetDateTime.now()
|
||||||
|
val accessCode = accessCodeRepo.findActiveByPropertyAndCode(request.propertyId, code, now)
|
||||||
|
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Invalid code")
|
||||||
|
|
||||||
|
val membershipId = PropertyUserId(propertyId = request.propertyId, userId = resolved.userId)
|
||||||
|
if (propertyUserRepo.existsById(membershipId)) {
|
||||||
|
throw ResponseStatusException(HttpStatus.CONFLICT, "User already a member")
|
||||||
|
}
|
||||||
|
|
||||||
|
val user = appUserRepo.findById(resolved.userId).orElseThrow {
|
||||||
|
ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
val propertyUser = PropertyUser(
|
||||||
|
id = membershipId,
|
||||||
|
property = accessCode.property,
|
||||||
|
user = user,
|
||||||
|
roles = accessCode.roles.toMutableSet()
|
||||||
|
)
|
||||||
|
propertyUserRepo.save(propertyUser)
|
||||||
|
|
||||||
|
accessCode.usedAt = now
|
||||||
|
accessCode.usedBy = user
|
||||||
|
accessCodeRepo.save(accessCode)
|
||||||
|
|
||||||
|
return PropertyUserResponse(
|
||||||
|
userId = membershipId.userId!!,
|
||||||
|
propertyId = membershipId.propertyId!!,
|
||||||
|
roles = propertyUser.roles.map { it.name }.toSet()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseRoles(input: Set<String>): Set<Role> {
|
||||||
|
return try {
|
||||||
|
input.map { Role.valueOf(it) }.toSet()
|
||||||
|
} catch (ex: IllegalArgumentException) {
|
||||||
|
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown role")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun generateCode(propertyId: UUID, now: OffsetDateTime): String {
|
||||||
|
repeat(6) {
|
||||||
|
val value = secureRandom.nextInt(1_000_000)
|
||||||
|
val code = String.format("%06d", value)
|
||||||
|
if (!accessCodeRepo.existsActiveByPropertyAndCode(propertyId, code, now)) {
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw ResponseStatusException(HttpStatus.CONFLICT, "Unable to generate code, try again")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package com.android.trisolarisserver.controller.property
|
||||||
|
|
||||||
|
import com.android.trisolarisserver.component.auth.PropertyAccess
|
||||||
|
import com.android.trisolarisserver.controller.common.requirePrincipal
|
||||||
|
import com.android.trisolarisserver.controller.common.requireSuperAdmin
|
||||||
|
import com.android.trisolarisserver.controller.dto.property.AppUserSummaryResponse
|
||||||
|
import com.android.trisolarisserver.controller.dto.property.PropertyUserDetailsResponse
|
||||||
|
import com.android.trisolarisserver.models.property.Role
|
||||||
|
import com.android.trisolarisserver.repo.property.AppUserRepo
|
||||||
|
import com.android.trisolarisserver.repo.property.PropertyUserRepo
|
||||||
|
import com.android.trisolarisserver.security.MyPrincipal
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
class UserDirectory(
|
||||||
|
private val appUserRepo: AppUserRepo,
|
||||||
|
private val propertyUserRepo: PropertyUserRepo,
|
||||||
|
private val propertyAccess: PropertyAccess
|
||||||
|
) {
|
||||||
|
|
||||||
|
@GetMapping("/users")
|
||||||
|
fun listAppUsers(
|
||||||
|
@AuthenticationPrincipal principal: MyPrincipal?,
|
||||||
|
@RequestParam(required = false) phone: String?
|
||||||
|
): List<AppUserSummaryResponse> {
|
||||||
|
requireSuperAdmin(appUserRepo, principal)
|
||||||
|
val digits = phone?.filter { it.isDigit() }.orEmpty()
|
||||||
|
val users = when {
|
||||||
|
phone == null -> appUserRepo.findAll()
|
||||||
|
digits.length < 6 -> return emptyList()
|
||||||
|
else -> appUserRepo.findByPhoneE164Containing(digits)
|
||||||
|
}
|
||||||
|
return users.map {
|
||||||
|
AppUserSummaryResponse(
|
||||||
|
id = it.id!!,
|
||||||
|
phoneE164 = it.phoneE164,
|
||||||
|
name = it.name,
|
||||||
|
disabled = it.disabled,
|
||||||
|
superAdmin = it.superAdmin
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/properties/{propertyId}/users/search")
|
||||||
|
fun searchPropertyUsers(
|
||||||
|
@PathVariable propertyId: UUID,
|
||||||
|
@AuthenticationPrincipal principal: MyPrincipal?,
|
||||||
|
@RequestParam(required = false) phone: String?
|
||||||
|
): List<PropertyUserDetailsResponse> {
|
||||||
|
val resolved = requirePrincipal(principal)
|
||||||
|
propertyAccess.requireMember(propertyId, resolved.userId)
|
||||||
|
propertyAccess.requireAnyRole(propertyId, resolved.userId, Role.ADMIN)
|
||||||
|
|
||||||
|
val digits = phone?.filter { it.isDigit() }.orEmpty()
|
||||||
|
val users = when {
|
||||||
|
phone == null -> propertyUserRepo.findByPropertyIdWithUser(propertyId)
|
||||||
|
digits.length < 6 -> return emptyList()
|
||||||
|
else -> propertyUserRepo.findByPropertyIdAndPhoneLike(propertyId, digits)
|
||||||
|
}
|
||||||
|
return users.map {
|
||||||
|
val user = it.user
|
||||||
|
PropertyUserDetailsResponse(
|
||||||
|
userId = it.id.userId!!,
|
||||||
|
propertyId = it.id.propertyId!!,
|
||||||
|
roles = it.roles.map { role -> role.name }.toSet(),
|
||||||
|
name = user.name,
|
||||||
|
phoneE164 = user.phoneE164,
|
||||||
|
disabled = user.disabled,
|
||||||
|
superAdmin = user.superAdmin
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package com.android.trisolarisserver.models.property
|
||||||
|
|
||||||
|
import jakarta.persistence.CollectionTable
|
||||||
|
import jakarta.persistence.Column
|
||||||
|
import jakarta.persistence.ElementCollection
|
||||||
|
import jakarta.persistence.Entity
|
||||||
|
import jakarta.persistence.FetchType
|
||||||
|
import jakarta.persistence.GeneratedValue
|
||||||
|
import jakarta.persistence.Id
|
||||||
|
import jakarta.persistence.Index
|
||||||
|
import jakarta.persistence.JoinColumn
|
||||||
|
import jakarta.persistence.ManyToOne
|
||||||
|
import jakarta.persistence.Table
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(
|
||||||
|
name = "property_access_code",
|
||||||
|
indexes = [
|
||||||
|
Index(name = "idx_property_access_code_property", columnList = "property_id"),
|
||||||
|
Index(name = "idx_property_access_code_code", columnList = "property_id,code")
|
||||||
|
]
|
||||||
|
)
|
||||||
|
class PropertyAccessCode(
|
||||||
|
@Id
|
||||||
|
@GeneratedValue
|
||||||
|
@Column(columnDefinition = "uuid")
|
||||||
|
val id: UUID? = null,
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "property_id")
|
||||||
|
var property: Property,
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "created_by_user_id")
|
||||||
|
var createdBy: AppUser,
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
var code: String,
|
||||||
|
|
||||||
|
@Column(name = "expires_at", nullable = false, columnDefinition = "timestamptz")
|
||||||
|
var expiresAt: OffsetDateTime,
|
||||||
|
|
||||||
|
@Column(name = "used_at", columnDefinition = "timestamptz")
|
||||||
|
var usedAt: OffsetDateTime? = null,
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "used_by_user_id")
|
||||||
|
var usedBy: AppUser? = null,
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false, columnDefinition = "timestamptz")
|
||||||
|
val createdAt: OffsetDateTime = OffsetDateTime.now(),
|
||||||
|
|
||||||
|
@ElementCollection(fetch = FetchType.EAGER)
|
||||||
|
@CollectionTable(
|
||||||
|
name = "property_access_code_role",
|
||||||
|
joinColumns = [JoinColumn(name = "access_code_id")]
|
||||||
|
)
|
||||||
|
@Column(name = "role")
|
||||||
|
var roles: MutableSet<Role> = mutableSetOf()
|
||||||
|
)
|
||||||
@@ -7,4 +7,5 @@ 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 findByPhoneE164Containing(phoneE164: String): List<AppUser>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package com.android.trisolarisserver.repo.property
|
||||||
|
|
||||||
|
import com.android.trisolarisserver.models.property.PropertyAccessCode
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
|
import org.springframework.data.jpa.repository.Query
|
||||||
|
import org.springframework.data.repository.query.Param
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
interface PropertyAccessCodeRepo : JpaRepository<PropertyAccessCode, UUID> {
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
select pac
|
||||||
|
from PropertyAccessCode pac
|
||||||
|
where pac.property.id = :propertyId
|
||||||
|
and pac.code = :code
|
||||||
|
and pac.usedAt is null
|
||||||
|
and pac.expiresAt > :now
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
fun findActiveByPropertyAndCode(
|
||||||
|
@Param("propertyId") propertyId: UUID,
|
||||||
|
@Param("code") code: String,
|
||||||
|
@Param("now") now: OffsetDateTime
|
||||||
|
): PropertyAccessCode?
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
select case when count(pac) > 0 then true else false end
|
||||||
|
from PropertyAccessCode pac
|
||||||
|
where pac.property.id = :propertyId
|
||||||
|
and pac.code = :code
|
||||||
|
and pac.usedAt is null
|
||||||
|
and pac.expiresAt > :now
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
fun existsActiveByPropertyAndCode(
|
||||||
|
@Param("propertyId") propertyId: UUID,
|
||||||
|
@Param("code") code: String,
|
||||||
|
@Param("now") now: OffsetDateTime
|
||||||
|
): Boolean
|
||||||
|
}
|
||||||
@@ -14,6 +14,31 @@ interface PropertyUserRepo : JpaRepository<PropertyUser, PropertyUserId> {
|
|||||||
|
|
||||||
fun findByIdPropertyId(propertyId: UUID): List<PropertyUser>
|
fun findByIdPropertyId(propertyId: UUID): List<PropertyUser>
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
select pu
|
||||||
|
from PropertyUser pu
|
||||||
|
join fetch pu.user u
|
||||||
|
where pu.id.propertyId = :propertyId
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
fun findByPropertyIdWithUser(@Param("propertyId") propertyId: UUID): List<PropertyUser>
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
select pu
|
||||||
|
from PropertyUser pu
|
||||||
|
join fetch pu.user u
|
||||||
|
where pu.id.propertyId = :propertyId
|
||||||
|
and u.phoneE164 is not null
|
||||||
|
and u.phoneE164 like concat('%', :phone, '%')
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
fun findByPropertyIdAndPhoneLike(
|
||||||
|
@Param("propertyId") propertyId: UUID,
|
||||||
|
@Param("phone") phone: String
|
||||||
|
): List<PropertyUser>
|
||||||
|
|
||||||
@Query("""
|
@Query("""
|
||||||
select r
|
select r
|
||||||
from PropertyUser pu join pu.roles r
|
from PropertyUser pu join pu.roles r
|
||||||
|
|||||||
Reference in New Issue
Block a user