Add temporary room cards with 7 minute expiry
Some checks failed
build-and-deploy / build-deploy (push) Failing after 28s
Some checks failed
build-and-deploy / build-deploy (push) Failing after 28s
This commit is contained in:
@@ -0,0 +1,38 @@
|
|||||||
|
package com.android.trisolarisserver.config
|
||||||
|
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import org.springframework.boot.ApplicationArguments
|
||||||
|
import org.springframework.boot.ApplicationRunner
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
|
||||||
|
@Component
|
||||||
|
class IssuedCardSchemaFix(
|
||||||
|
private val jdbcTemplate: JdbcTemplate
|
||||||
|
) : ApplicationRunner {
|
||||||
|
|
||||||
|
private val logger = LoggerFactory.getLogger(IssuedCardSchemaFix::class.java)
|
||||||
|
|
||||||
|
override fun run(args: ApplicationArguments) {
|
||||||
|
val version = jdbcTemplate.queryForObject("select version()", String::class.java) ?: return
|
||||||
|
if (!version.contains("PostgreSQL", ignoreCase = true)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val isNullable = jdbcTemplate.queryForObject(
|
||||||
|
"""
|
||||||
|
select count(*)
|
||||||
|
from information_schema.columns
|
||||||
|
where table_name = 'issued_card'
|
||||||
|
and column_name = 'room_stay_id'
|
||||||
|
and is_nullable = 'YES'
|
||||||
|
""".trimIndent(),
|
||||||
|
Int::class.java
|
||||||
|
) ?: 0
|
||||||
|
|
||||||
|
if (isNullable == 0) {
|
||||||
|
logger.info("Dropping NOT NULL on issued_card.room_stay_id")
|
||||||
|
jdbcTemplate.execute("alter table issued_card alter column room_stay_id drop not null")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -301,7 +301,7 @@ private fun IssuedCard.toResponse(): IssuedCardResponse {
|
|||||||
id = id!!,
|
id = id!!,
|
||||||
propertyId = property.id!!,
|
propertyId = property.id!!,
|
||||||
roomId = room.id!!,
|
roomId = room.id!!,
|
||||||
roomStayId = roomStay.id!!,
|
roomStayId = roomStay?.id,
|
||||||
cardId = cardId,
|
cardId = cardId,
|
||||||
cardIndex = cardIndex,
|
cardIndex = cardIndex,
|
||||||
issuedAt = issuedAt.toString(),
|
issuedAt = issuedAt.toString(),
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
package com.android.trisolarisserver.controller
|
||||||
|
|
||||||
|
import com.android.trisolarisserver.component.PropertyAccess
|
||||||
|
import com.android.trisolarisserver.controller.dto.CardPrepareResponse
|
||||||
|
import com.android.trisolarisserver.controller.dto.IssueCardRequest
|
||||||
|
import com.android.trisolarisserver.controller.dto.IssuedCardResponse
|
||||||
|
import com.android.trisolarisserver.models.property.Role
|
||||||
|
import com.android.trisolarisserver.models.room.IssuedCard
|
||||||
|
import com.android.trisolarisserver.repo.AppUserRepo
|
||||||
|
import com.android.trisolarisserver.repo.IssuedCardRepo
|
||||||
|
import com.android.trisolarisserver.repo.PropertyCardCounterRepo
|
||||||
|
import com.android.trisolarisserver.repo.PropertyRepo
|
||||||
|
import com.android.trisolarisserver.repo.RoomRepo
|
||||||
|
import com.android.trisolarisserver.security.MyPrincipal
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.ResponseStatus
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import org.springframework.web.server.ResponseStatusException
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/properties/{propertyId}/rooms/{roomId}/cards")
|
||||||
|
class TemporaryRoomCards(
|
||||||
|
private val propertyAccess: PropertyAccess,
|
||||||
|
private val roomRepo: RoomRepo,
|
||||||
|
private val issuedCardRepo: IssuedCardRepo,
|
||||||
|
private val appUserRepo: AppUserRepo,
|
||||||
|
private val counterRepo: PropertyCardCounterRepo,
|
||||||
|
private val propertyRepo: PropertyRepo
|
||||||
|
) {
|
||||||
|
private val temporaryCardDurationMinutes = 7L
|
||||||
|
|
||||||
|
@PostMapping("/prepare-temp")
|
||||||
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
|
@Transactional
|
||||||
|
fun prepareTemporary(
|
||||||
|
@PathVariable propertyId: UUID,
|
||||||
|
@PathVariable roomId: UUID,
|
||||||
|
@AuthenticationPrincipal principal: MyPrincipal?
|
||||||
|
): CardPrepareResponse {
|
||||||
|
requireIssueActor(propertyId, principal)
|
||||||
|
val room = roomRepo.findByIdAndPropertyId(roomId, propertyId)
|
||||||
|
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room not found")
|
||||||
|
|
||||||
|
val issuedAt = OffsetDateTime.now()
|
||||||
|
val expiresAt = issuedAt.plusMinutes(temporaryCardDurationMinutes)
|
||||||
|
val cardIndex = nextCardIndex(propertyId)
|
||||||
|
val payload = buildSector0Payload(room.roomNumber, cardIndex, issuedAt, expiresAt)
|
||||||
|
return CardPrepareResponse(
|
||||||
|
cardIndex = cardIndex,
|
||||||
|
key = payload.key,
|
||||||
|
timeData = payload.timeData,
|
||||||
|
issuedAt = issuedAt.toString(),
|
||||||
|
expiresAt = expiresAt.toString()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/temp")
|
||||||
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
|
@Transactional
|
||||||
|
fun issueTemporary(
|
||||||
|
@PathVariable propertyId: UUID,
|
||||||
|
@PathVariable roomId: UUID,
|
||||||
|
@AuthenticationPrincipal principal: MyPrincipal?,
|
||||||
|
@RequestBody request: IssueCardRequest
|
||||||
|
): IssuedCardResponse {
|
||||||
|
val actor = requireIssueActor(propertyId, principal)
|
||||||
|
if (request.cardId.isBlank()) {
|
||||||
|
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "cardId required")
|
||||||
|
}
|
||||||
|
if (request.cardIndex <= 0) {
|
||||||
|
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "cardIndex required")
|
||||||
|
}
|
||||||
|
val room = roomRepo.findByIdAndPropertyId(roomId, propertyId)
|
||||||
|
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room not found")
|
||||||
|
|
||||||
|
val issuedAt = parseOffset(request.issuedAt) ?: OffsetDateTime.now()
|
||||||
|
val expiresAt = issuedAt.plusMinutes(temporaryCardDurationMinutes)
|
||||||
|
val now = OffsetDateTime.now()
|
||||||
|
if (issuedCardRepo.existsActiveForRoom(propertyId, room.id!!, now)) {
|
||||||
|
throw ResponseStatusException(HttpStatus.CONFLICT, "Active card already exists for room")
|
||||||
|
}
|
||||||
|
|
||||||
|
val card = IssuedCard(
|
||||||
|
property = room.property,
|
||||||
|
room = room,
|
||||||
|
roomStay = null,
|
||||||
|
cardId = request.cardId.trim(),
|
||||||
|
cardIndex = request.cardIndex,
|
||||||
|
issuedAt = issuedAt,
|
||||||
|
expiresAt = expiresAt,
|
||||||
|
issuedBy = actor
|
||||||
|
)
|
||||||
|
return issuedCardRepo.save(card).toResponse()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseOffset(value: String?): OffsetDateTime? {
|
||||||
|
if (value.isNullOrBlank()) return null
|
||||||
|
return try {
|
||||||
|
OffsetDateTime.parse(value.trim())
|
||||||
|
} catch (_: Exception) {
|
||||||
|
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid timestamp")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requireIssueActor(propertyId: UUID, principal: MyPrincipal?): com.android.trisolarisserver.models.property.AppUser {
|
||||||
|
if (principal == null) {
|
||||||
|
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
|
||||||
|
}
|
||||||
|
propertyAccess.requireMember(propertyId, principal.userId)
|
||||||
|
propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN, Role.MANAGER)
|
||||||
|
return appUserRepo.findById(principal.userId).orElseThrow {
|
||||||
|
ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun nextCardIndex(propertyId: UUID): Int {
|
||||||
|
var counter = counterRepo.findByPropertyIdForUpdate(propertyId)
|
||||||
|
if (counter == null) {
|
||||||
|
val property = propertyRepo.findById(propertyId).orElseThrow {
|
||||||
|
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
|
||||||
|
}
|
||||||
|
counterRepo.save(com.android.trisolarisserver.models.room.PropertyCardCounter(
|
||||||
|
property = property,
|
||||||
|
nextIndex = 10001
|
||||||
|
))
|
||||||
|
counter = counterRepo.findByPropertyIdForUpdate(propertyId)
|
||||||
|
}
|
||||||
|
val current = counter!!.nextIndex
|
||||||
|
counter.nextIndex = current + 1
|
||||||
|
counter.updatedAt = OffsetDateTime.now()
|
||||||
|
counterRepo.save(counter)
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildSector0Payload(
|
||||||
|
roomNumber: Int,
|
||||||
|
cardIndex: Int,
|
||||||
|
issuedAt: OffsetDateTime,
|
||||||
|
expiresAt: OffsetDateTime
|
||||||
|
): Sector0Payload {
|
||||||
|
val key = buildSector0Block2(roomNumber, cardIndex)
|
||||||
|
val newData = "0000000000" + formatDateComponents(issuedAt) + formatDateComponents(expiresAt)
|
||||||
|
val checkSum = calculateChecksum(key + newData)
|
||||||
|
val finalData = newData + checkSum
|
||||||
|
return Sector0Payload(key, finalData)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildSector0Block2(roomNumber: Int, cardID: Int): String {
|
||||||
|
val guestID = cardID + 1
|
||||||
|
val key = "${cardID}2F${guestID}"
|
||||||
|
val finalRoom = if (roomNumber < 10) "0$roomNumber" else roomNumber.toString()
|
||||||
|
return "472F${key}00010000${finalRoom}0000"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatDateComponents(time: OffsetDateTime): String {
|
||||||
|
val minute = time.minute.toString().padStart(2, '0')
|
||||||
|
val hour = time.hour.toString().padStart(2, '0')
|
||||||
|
val day = time.dayOfMonth.toString().padStart(2, '0')
|
||||||
|
val month = time.monthValue.toString().padStart(2, '0')
|
||||||
|
val year = time.year.toString().takeLast(2)
|
||||||
|
return "${minute}${hour}${day}${month}${year}"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun calculateChecksum(dataHex: String): String {
|
||||||
|
val data = hexStringToByteArray(dataHex)
|
||||||
|
var checksum = 0
|
||||||
|
for (byte in data) {
|
||||||
|
checksum = calculateByteChecksum(byte, checksum)
|
||||||
|
}
|
||||||
|
return String.format("%02X", checksum)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun calculateByteChecksum(byte: Byte, currentChecksum: Int): Int {
|
||||||
|
var checksum = currentChecksum
|
||||||
|
var b = byte.toInt()
|
||||||
|
for (i in 0 until 8) {
|
||||||
|
checksum = if ((checksum xor b) and 1 != 0) {
|
||||||
|
(checksum xor 0x18) shr 1 or 0x80
|
||||||
|
} else {
|
||||||
|
checksum shr 1
|
||||||
|
}
|
||||||
|
b = b shr 1
|
||||||
|
}
|
||||||
|
return checksum
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hexStringToByteArray(hexString: String): ByteArray {
|
||||||
|
val len = hexString.length
|
||||||
|
val data = ByteArray(len / 2)
|
||||||
|
for (i in 0 until len step 2) {
|
||||||
|
data[i / 2] = ((Character.digit(hexString[i], 16) shl 4)
|
||||||
|
+ Character.digit(hexString[i + 1], 16)).toByte()
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class Sector0Payload(
|
||||||
|
val key: String,
|
||||||
|
val timeData: String
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun IssuedCard.toResponse(): IssuedCardResponse {
|
||||||
|
return IssuedCardResponse(
|
||||||
|
id = id!!,
|
||||||
|
propertyId = property.id!!,
|
||||||
|
roomId = room.id!!,
|
||||||
|
roomStayId = roomStay?.id,
|
||||||
|
cardId = cardId,
|
||||||
|
cardIndex = cardIndex,
|
||||||
|
issuedAt = issuedAt.toString(),
|
||||||
|
expiresAt = expiresAt.toString(),
|
||||||
|
issuedByUserId = issuedBy?.id,
|
||||||
|
revokedAt = revokedAt?.toString()
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -57,7 +57,7 @@ data class IssuedCardResponse(
|
|||||||
val id: UUID,
|
val id: UUID,
|
||||||
val propertyId: UUID,
|
val propertyId: UUID,
|
||||||
val roomId: UUID,
|
val roomId: UUID,
|
||||||
val roomStayId: UUID,
|
val roomStayId: UUID?,
|
||||||
val cardId: String,
|
val cardId: String,
|
||||||
val cardIndex: Int,
|
val cardIndex: Int,
|
||||||
val issuedAt: String,
|
val issuedAt: String,
|
||||||
|
|||||||
@@ -31,9 +31,9 @@ class IssuedCard(
|
|||||||
@JoinColumn(name = "room_id", nullable = false)
|
@JoinColumn(name = "room_id", nullable = false)
|
||||||
var room: Room,
|
var room: Room,
|
||||||
|
|
||||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
@ManyToOne(fetch = FetchType.LAZY, optional = true)
|
||||||
@JoinColumn(name = "room_stay_id", nullable = false)
|
@JoinColumn(name = "room_stay_id", nullable = true)
|
||||||
var roomStay: RoomStay,
|
var roomStay: RoomStay? = null,
|
||||||
|
|
||||||
@Column(name = "card_id", nullable = false)
|
@Column(name = "card_id", nullable = false)
|
||||||
var cardId: String,
|
var cardId: String,
|
||||||
|
|||||||
Reference in New Issue
Block a user