From e3420974e50e9ee2e3db037f9bd7ba7cf6e70275 Mon Sep 17 00:00:00 2001 From: androidlover5842 Date: Wed, 28 Jan 2026 06:24:19 +0530 Subject: [PATCH] Add temporary room cards with 7 minute expiry --- .../config/IssuedCardSchemaFix.kt | 38 +++ .../controller/IssuedCards.kt | 2 +- .../controller/TemporaryRoomCards.kt | 224 ++++++++++++++++++ .../controller/dto/BookingDtos.kt | 2 +- .../models/room/IssuedCard.kt | 6 +- 5 files changed, 267 insertions(+), 5 deletions(-) create mode 100644 src/main/kotlin/com/android/trisolarisserver/config/IssuedCardSchemaFix.kt create mode 100644 src/main/kotlin/com/android/trisolarisserver/controller/TemporaryRoomCards.kt diff --git a/src/main/kotlin/com/android/trisolarisserver/config/IssuedCardSchemaFix.kt b/src/main/kotlin/com/android/trisolarisserver/config/IssuedCardSchemaFix.kt new file mode 100644 index 0000000..43d88b1 --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/config/IssuedCardSchemaFix.kt @@ -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") + } + } +} diff --git a/src/main/kotlin/com/android/trisolarisserver/controller/IssuedCards.kt b/src/main/kotlin/com/android/trisolarisserver/controller/IssuedCards.kt index 3c4a47c..e618b57 100644 --- a/src/main/kotlin/com/android/trisolarisserver/controller/IssuedCards.kt +++ b/src/main/kotlin/com/android/trisolarisserver/controller/IssuedCards.kt @@ -301,7 +301,7 @@ private fun IssuedCard.toResponse(): IssuedCardResponse { id = id!!, propertyId = property.id!!, roomId = room.id!!, - roomStayId = roomStay.id!!, + roomStayId = roomStay?.id, cardId = cardId, cardIndex = cardIndex, issuedAt = issuedAt.toString(), diff --git a/src/main/kotlin/com/android/trisolarisserver/controller/TemporaryRoomCards.kt b/src/main/kotlin/com/android/trisolarisserver/controller/TemporaryRoomCards.kt new file mode 100644 index 0000000..5f759a8 --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/controller/TemporaryRoomCards.kt @@ -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() + ) +} diff --git a/src/main/kotlin/com/android/trisolarisserver/controller/dto/BookingDtos.kt b/src/main/kotlin/com/android/trisolarisserver/controller/dto/BookingDtos.kt index 12ffd7d..61df364 100644 --- a/src/main/kotlin/com/android/trisolarisserver/controller/dto/BookingDtos.kt +++ b/src/main/kotlin/com/android/trisolarisserver/controller/dto/BookingDtos.kt @@ -57,7 +57,7 @@ data class IssuedCardResponse( val id: UUID, val propertyId: UUID, val roomId: UUID, - val roomStayId: UUID, + val roomStayId: UUID?, val cardId: String, val cardIndex: Int, val issuedAt: String, diff --git a/src/main/kotlin/com/android/trisolarisserver/models/room/IssuedCard.kt b/src/main/kotlin/com/android/trisolarisserver/models/room/IssuedCard.kt index 2fa900b..7af6b5c 100644 --- a/src/main/kotlin/com/android/trisolarisserver/models/room/IssuedCard.kt +++ b/src/main/kotlin/com/android/trisolarisserver/models/room/IssuedCard.kt @@ -31,9 +31,9 @@ class IssuedCard( @JoinColumn(name = "room_id", nullable = false) var room: Room, - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "room_stay_id", nullable = false) - var roomStay: RoomStay, + @ManyToOne(fetch = FetchType.LAZY, optional = true) + @JoinColumn(name = "room_stay_id", nullable = true) + var roomStay: RoomStay? = null, @Column(name = "card_id", nullable = false) var cardId: String,