move card issuence server side

This commit is contained in:
androidlover5842
2026-01-24 23:53:03 +05:30
parent ab7f02ddc6
commit 094673b475
7 changed files with 553 additions and 95 deletions

181
AGENTS.md
View File

@@ -1,67 +1,36 @@
PROJECT CONTEXT / SYSTEM BRIEF PROJECT CONTEXT / SYSTEM BRIEF
This is a hotel-grade Property Management System (PMS) being rebuilt from scratch. This is a hotel-grade Property Management System (PMS) being rebuilt from scratch.
This AGENTS file captures the product rules + current codebase state. This AGENTS file captures product rules + current codebase state.
Tech stack Tech stack
- Spring Boot monolith - Spring Boot monolith
- Kotlin only - Kotlin only
- JPA / Hibernate - JPA / Hibernate
- PostgreSQL - PostgreSQL
- No Flyway for now, schema via JPA during development (Flyway deps present but disabled) - Flyway deps present but disabled (no migrations during dev)
- Single API domain api.hoteltrisolaris.in - Single API domain api.hoteltrisolaris.in
- Android app and future website consume the same APIs Server specs (current)
- CPU: i5-8400
- RAM: 48 GB
- GPU: RTX 3060 (used for llama.cpp)
Legacy note Core principles
Old Firestore Android app exists only to understand behavior. Do not reuse old models. - Server is source of truth; clients send intent.
- Ledger-based design: never store totals; append rows only.
- Occupancy = RoomStay. Billing = Charge. Payments = Payment. Invoices are derived.
- 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.
CORE DESIGN PRINCIPLES Immutable rules
- Use Kotlin only; no microservices.
Server is the only source of truth - Flyway must remain disabled until schema stabilizes.
Clients never calculate state - Canonical staff roles: ADMIN, MANAGER, STAFF, HOUSEKEEPING, FINANCE.
Clients send intent, server derives facts - Booking does not own rooms or money.
- Realtime events must be derived, not raw DB changes.
Ledger-based design - Ask before touching auth or payment logic.
Never store totals
Never overwrite money
Append rows and derive views
Three independent concerns
Occupancy handled by RoomStay
Charges handled by Charge
Payments handled by Payment
Invoices are derived views, not stored state
Room availability is by room number
Availability is derived from RoomStay
toAt = null means occupied
Category counts are only summaries
Room changes must never break billing
Changing rooms means closing one RoomStay and opening another
Charges are time-bound and linked to booking, optionally to room_stay
Multi-property from day one
Every domain object is scoped to property_id
Users belong to organization
Access is granted per property
Auth and access
User exists once as AppUser
Property access via PropertyUser
Roles are per property
Every API call must enforce property membership
IMMUTABLE RULES
- Use Kotlin only
- Follow existing package structure
- No speculative features
- No premature microservices
- Flyway must remain disabled during development. Do not introduce or modify Flyway migrations unless explicitly instructed after schema stabilization.
- Canonical staff roles: ADMIN, MANAGER, STAFF, HOUSEKEEPING, FINANCE (other roles may exist but must not be depended on)
- Booking is a lifecycle container only. Booking does not own rooms or money. Occupancy via RoomStay. Billing via Charge/Payment ledgers.
- Realtime must emit derived events only (no raw entity subscriptions)
- Ask before touching auth or payment logic
=============================================================================== ===============================================================================
CURRENT CODEBASE UNDERSTANDING (TrisolarisServer) CURRENT CODEBASE UNDERSTANDING (TrisolarisServer)
@@ -73,72 +42,94 @@ Repository
- Scheduling enabled (@EnableScheduling) - Scheduling enabled (@EnableScheduling)
Security/Auth Security/Auth
- Firebase Admin auth for every request, Firebase UID required. - Firebase Admin auth for every request; Firebase UID required.
- Security filter verifies token and maps to MyPrincipal(userId, firebaseUid). - /auth/verify and /auth/me.
- Endpoints: /auth/verify and /auth/me.
Domain entities Domain entities
- Organization: id, name, emailAliases - Organization: name, emailAliases, allowedTransportModes.
- Property: org, code, name, addressText, timezone, currency, active, emailAddresses, otaAliases - Property: code, name, addressText, emailAddresses, otaAliases, allowedTransportModes.
- AppUser: org, firebaseUid, phoneE164, name, disabled - AppUser, PropertyUser (roles per property).
- PropertyUser: roles per property - RoomType: code/name/occupancy + otaAliases.
- Role enum includes ADMIN, MANAGER, STAFF, HOUSEKEEPING, FINANCE, GUIDE, SUPERVISOR, AGENT (but only canonical roles should be used) - Room: roomNumber, floor, hasNfc, active, maintenance, notes.
- RoomType: code/name/occupancy + otaAliases - Booking: status, expected check-in/out, emailAuditPdfUrl, transportMode, transportVehicleNumber.
- Room: roomNumber, floor, hasNfc, active, maintenance, notes - Guest (org-scoped).
- Booking: status, source/sourceBookingId, expected check-in/out, emailAuditPdfUrl - RoomStay.
- Guest - RoomStayChange (idempotent room move).
- RoomStay - IssuedCard (cardId, cardIndex, issuedAt, expiresAt, issuedBy, revokedAt).
- GuestDocument: files for guest/booking with AI-extracted data - PropertyCardCounter (per-property cardIndex counter).
- InboundEmail: inbound mail audit (PDF + raw eml), extractedData, status - GuestDocument (files + AI-extracted json).
- GuestVehicle (org-scoped vehicle numbers).
Repos - InboundEmail (audit PDF + raw EML, extracted json, status).
- Repos are under com.android.trisolarisserver.repo (note: not db.repo). - RoomImage (original + thumbnail).
- Added repos for Booking, Guest, GuestDocument, InboundEmail.
Key modules Key modules
Rooms / inventory Rooms / inventory
- /properties/{propertyId}/rooms - /properties/{propertyId}/rooms
- /properties/{propertyId}/rooms/board - /properties/{propertyId}/rooms/board
- /properties/{propertyId}/rooms/board/stream (SSE)
- /properties/{propertyId}/rooms/availability - /properties/{propertyId}/rooms/availability
- /properties/{propertyId}/rooms/availability-range?from=YYYY-MM-DD&to=YYYY-MM-DD - /properties/{propertyId}/rooms/availability-range?from=YYYY-MM-DD&to=YYYY-MM-DD
Availability derived from RoomStay. Date range uses overlap [from, to).
Room types Room types
- CRUD with otaAliases in DTOs. - CRUD with otaAliases.
Properties/Orgs Properties / Orgs
- Property create/update accept addressText, otaAliases, emailAddresses. - Property create/update accepts addressText, otaAliases, emailAddresses, allowedTransportModes.
- Org create/get returns emailAliases. - Org create/get returns emailAliases + allowedTransportModes.
Guest documents (files) Booking flow
- POST /properties/{propertyId}/guests/{guestId}/documents (multipart + bookingId) - /bookings/{id}/check-in (creates RoomStay rows)
- GET list - /bookings/{id}/check-out (closes RoomStay)
- GET file (token or auth) - /bookings/{id}/cancel, /no-show
- Files stored under /home/androidlover5842/docs/{propertyId}/{guestId}/{bookingId}/ - /bookings/{id}/room-stays (pre-assign RoomStay with date range)
- AI extraction via llama.cpp with strict system prompt - /room-stays/{id}/change-room (idempotent via RoomStayChange)
Card issuing
- /room-stays/{id}/cards/prepare -> returns cardIndex + sector0 payload
- /room-stays/{id}/cards -> store issued card
- /room-stays/{id}/cards (list)
- /room-stays/cards/{id}/revoke (ADMIN only)
Guest APIs
- /properties/{propertyId}/guests/search?phone=... or ?vehicleNumber=...
- /properties/{propertyId}/guests/{guestId}/vehicles (add vehicle)
Guest documents
- /properties/{propertyId}/guests/{guestId}/documents (upload/list/file)
- AI extraction with strict system prompt.
Room images
- /properties/{propertyId}/rooms/{roomId}/images (upload/list/file)
- Thumbnails generated (320px).
Transport modes
- /properties/{propertyId}/transport-modes -> returns enabled list (property > org > default all).
Inbound email ingestion Inbound email ingestion
- IMAP poller (1 min) with enable flag. - IMAP poller (1 min) with enable flag.
- Saves audit PDF + raw .eml to /home/androidlover5842/docs/emails - Saves audit PDF + raw .eml under /home/androidlover5842/docs/emails.
- Matches property via To/CC email first, then text aliases (name/code/address/otaAliases) - Property match: To/CC email first; fallback to name/code/address/otaAliases.
- AI extracts booking fields and creates/cancels Booking - AI extracts booking fields; creates/cancels Booking.
- Booking gets emailAuditPdfUrl that points to /properties/{propertyId}/inbound-emails/{emailId}/file - Booking emailAuditPdfUrl -> /properties/{propertyId}/inbound-emails/{emailId}/file
- Manual upload: POST /properties/{propertyId}/inbound-emails/manual (PDF).
Realtime
- SSE room board events with heartbeat, on room create/update, check-in/out, and room change.
AI integration AI integration
- Base URL configured per profile: - Base URL per profile: dev=https://ai.hoteltrisolaris.in/v1/chat/completions, prod=http://localhost:8089/v1/chat/completions
- dev: https://ai.hoteltrisolaris.in/v1/chat/completions - LlamaClient uses strict system prompt (no guessing).
- prod: http://localhost:8089/v1/chat/completions
- LlamaClient uses strict system prompt: only visible text, no guessing.
- Read timeout 5 minutes. - Read timeout 5 minutes.
Config Config
- application.properties: flyway disabled, storage paths, IMAP config, token secrets. - storage.documents.root=/home/androidlover5842/docs
- storage.documents.publicBaseUrl + token secret/ttl. - storage.emails.root=/home/androidlover5842/docs/emails
- storage.emails.publicBaseUrl used for booking audit URL. - storage.rooms.root=/home/androidlover5842/docs/rooms
- mail.imap.enabled=false by default. - publicBaseUrl entries for docs/emails/rooms
- mail.imap.enabled=false by default
Notes / constraints Notes / constraints
- API user creation removed; users are created by app; API only manages roles. - Users are created by app; API only manages roles.
- 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

@@ -0,0 +1,298 @@
package com.android.trisolarisserver.controller
import com.android.trisolarisserver.component.PropertyAccess
import com.android.trisolarisserver.controller.dto.CardPrepareRequest
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.RoomStayRepo
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.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.time.OffsetDateTime
import java.util.UUID
@RestController
@RequestMapping("/properties/{propertyId}/room-stays")
class IssuedCards(
private val propertyAccess: PropertyAccess,
private val roomStayRepo: RoomStayRepo,
private val issuedCardRepo: IssuedCardRepo,
private val appUserRepo: AppUserRepo,
private val counterRepo: PropertyCardCounterRepo,
private val propertyRepo: PropertyRepo
) {
@PostMapping("/{roomStayId}/cards/prepare")
@ResponseStatus(HttpStatus.CREATED)
@Transactional
fun prepare(
@PathVariable propertyId: UUID,
@PathVariable roomStayId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: CardPrepareRequest
): CardPrepareResponse {
requireIssueActor(propertyId, principal)
val stay = roomStayRepo.findById(roomStayId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Room stay not found")
}
if (stay.property.id != propertyId) {
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room stay not found for property")
}
if (stay.toAt != null) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Room stay closed")
}
val issuedAt = OffsetDateTime.now()
val expiresAt = request.expiresAt?.let { parseOffset(it) } ?: stay.toAt
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "expiresAt required")
if (!expiresAt.isAfter(issuedAt)) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "expiresAt must be after issuedAt")
}
val cardIndex = nextCardIndex(stay.property.id!!)
val payload = buildSector0Payload(stay.room.roomNumber, cardIndex, issuedAt, expiresAt)
return CardPrepareResponse(
cardIndex = cardIndex,
key = payload.key,
timeData = payload.timeData,
issuedAt = issuedAt.toString(),
expiresAt = expiresAt.toString()
)
}
@PostMapping("/{roomStayId}/cards")
@ResponseStatus(HttpStatus.CREATED)
@Transactional
fun issue(
@PathVariable propertyId: UUID,
@PathVariable roomStayId: 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 stay = roomStayRepo.findById(roomStayId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Room stay not found")
}
if (stay.property.id != propertyId) {
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room stay not found for property")
}
if (stay.toAt != null) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Room stay closed")
}
val issuedAt = parseOffset(request.issuedAt) ?: OffsetDateTime.now()
val expiresAt = parseOffset(request.expiresAt)
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "expiresAt required")
if (!expiresAt.isAfter(issuedAt)) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "expiresAt must be after issuedAt")
}
val now = OffsetDateTime.now()
if (issuedCardRepo.existsActiveForRoomStay(roomStayId, now)) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Active card already exists for room stay")
}
if (issuedCardRepo.existsActiveForRoom(propertyId, stay.room.id!!, now)) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Active card already exists for room")
}
val card = IssuedCard(
property = stay.property,
room = stay.room,
roomStay = stay,
cardId = request.cardId.trim(),
cardIndex = request.cardIndex,
issuedAt = issuedAt,
expiresAt = expiresAt,
issuedBy = actor
)
return issuedCardRepo.save(card).toResponse()
}
@GetMapping("/{roomStayId}/cards")
fun list(
@PathVariable propertyId: UUID,
@PathVariable roomStayId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
): List<IssuedCardResponse> {
requireMember(propertyId, principal)
val stay = roomStayRepo.findById(roomStayId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Room stay not found")
}
if (stay.property.id != propertyId) {
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room stay not found for property")
}
return issuedCardRepo.findByRoomStayIdOrderByIssuedAtDesc(roomStayId)
.map { it.toResponse() }
}
@PostMapping("/cards/{cardId}/revoke")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun revoke(
@PathVariable propertyId: UUID,
@PathVariable cardId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
) {
requireRevokeActor(propertyId, principal)
val card = issuedCardRepo.findByIdAndPropertyId(cardId, propertyId)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Card not found")
if (card.revokedAt == null) {
card.revokedAt = OffsetDateTime.now()
issuedCardRepo.save(card)
}
}
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 requireMember(propertyId: UUID, principal: MyPrincipal?) {
if (principal == null) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
}
propertyAccess.requireMember(propertyId, principal.userId)
}
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 requireRevokeActor(propertyId: UUID, principal: MyPrincipal?) {
if (principal == null) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
}
propertyAccess.requireMember(propertyId, principal.userId)
propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN)
}
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 = 1
))
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()
)
}

View File

@@ -45,3 +45,35 @@ data class RoomStayPreAssignRequest(
val toAt: String, val toAt: String,
val notes: String? = null val notes: String? = null
) )
data class IssueCardRequest(
val cardId: String,
val cardIndex: Int,
val issuedAt: String? = null,
val expiresAt: String
)
data class IssuedCardResponse(
val id: UUID,
val propertyId: UUID,
val roomId: UUID,
val roomStayId: UUID,
val cardId: String,
val cardIndex: Int,
val issuedAt: String,
val expiresAt: String,
val issuedByUserId: UUID?,
val revokedAt: String?
)
data class CardPrepareRequest(
val expiresAt: String? = null
)
data class CardPrepareResponse(
val cardIndex: Int,
val key: String,
val timeData: String,
val issuedAt: String,
val expiresAt: String
)

View File

@@ -0,0 +1,56 @@
package com.android.trisolarisserver.models.room
import com.android.trisolarisserver.models.property.AppUser
import com.android.trisolarisserver.models.property.Property
import jakarta.persistence.*
import java.time.OffsetDateTime
import java.util.UUID
@Entity
@Table(
name = "issued_card",
uniqueConstraints = [
UniqueConstraint(columnNames = ["property_id", "card_index"])
],
indexes = [
Index(name = "idx_issued_card_room_stay", columnList = "room_stay_id"),
Index(name = "idx_issued_card_card_id", columnList = "card_id")
]
)
class IssuedCard(
@Id
@GeneratedValue
@Column(columnDefinition = "uuid")
val id: UUID? = null,
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "property_id", nullable = false)
var property: Property,
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@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,
@Column(name = "card_id", nullable = false)
var cardId: String,
@Column(name = "card_index", nullable = false)
var cardIndex: Int,
@Column(name = "issued_at", nullable = false, columnDefinition = "timestamptz")
var issuedAt: OffsetDateTime,
@Column(name = "expires_at", nullable = false, columnDefinition = "timestamptz")
var expiresAt: OffsetDateTime,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "issued_by")
var issuedBy: AppUser? = null,
@Column(name = "revoked_at", columnDefinition = "timestamptz")
var revokedAt: OffsetDateTime? = null
)

View File

@@ -0,0 +1,28 @@
package com.android.trisolarisserver.models.room
import com.android.trisolarisserver.models.property.Property
import jakarta.persistence.*
import java.time.OffsetDateTime
import java.util.UUID
@Entity
@Table(
name = "property_card_counter",
uniqueConstraints = [UniqueConstraint(columnNames = ["property_id"])]
)
class PropertyCardCounter(
@Id
@GeneratedValue
@Column(columnDefinition = "uuid")
val id: UUID? = null,
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "property_id", nullable = false)
var property: Property,
@Column(name = "next_index", nullable = false)
var nextIndex: Int = 1,
@Column(name = "updated_at", nullable = false, columnDefinition = "timestamptz")
var updatedAt: OffsetDateTime = OffsetDateTime.now()
)

View File

@@ -0,0 +1,36 @@
package com.android.trisolarisserver.repo
import com.android.trisolarisserver.models.room.IssuedCard
import org.springframework.data.jpa.repository.JpaRepository
import java.util.UUID
interface IssuedCardRepo : JpaRepository<IssuedCard, UUID> {
fun findByRoomStayIdOrderByIssuedAtDesc(roomStayId: UUID): List<IssuedCard>
fun findByIdAndPropertyId(id: UUID, propertyId: UUID): IssuedCard?
@org.springframework.data.jpa.repository.Query("""
select case when count(c) > 0 then true else false end
from IssuedCard c
where c.property.id = :propertyId
and c.room.id = :roomId
and c.revokedAt is null
and c.expiresAt > :now
""")
fun existsActiveForRoom(
@org.springframework.data.repository.query.Param("propertyId") propertyId: UUID,
@org.springframework.data.repository.query.Param("roomId") roomId: UUID,
@org.springframework.data.repository.query.Param("now") now: java.time.OffsetDateTime
): Boolean
@org.springframework.data.jpa.repository.Query("""
select case when count(c) > 0 then true else false end
from IssuedCard c
where c.roomStay.id = :roomStayId
and c.revokedAt is null
and c.expiresAt > :now
""")
fun existsActiveForRoomStay(
@org.springframework.data.repository.query.Param("roomStayId") roomStayId: UUID,
@org.springframework.data.repository.query.Param("now") now: java.time.OffsetDateTime
): Boolean
}

View File

@@ -0,0 +1,17 @@
package com.android.trisolarisserver.repo
import com.android.trisolarisserver.models.room.PropertyCardCounter
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Lock
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import java.util.UUID
import jakarta.persistence.LockModeType
interface PropertyCardCounterRepo : JpaRepository<PropertyCardCounter, UUID> {
fun findByPropertyId(propertyId: UUID): PropertyCardCounter?
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select c from PropertyCardCounter c where c.property.id = :propertyId")
fun findByPropertyIdForUpdate(@Param("propertyId") propertyId: UUID): PropertyCardCounter?
}