diff --git a/AGENTS.md b/AGENTS.md index 66f49fd..9dc94e9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,67 +1,36 @@ PROJECT CONTEXT / SYSTEM BRIEF 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 - Spring Boot monolith - Kotlin only - JPA / Hibernate - 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 -- 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 -Old Firestore Android app exists only to understand behavior. Do not reuse old models. +Core principles +- 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 - -Server is the only source of truth -Clients never calculate state -Clients send intent, server derives facts - -Ledger-based design -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 +Immutable rules +- Use Kotlin only; no microservices. +- Flyway must remain disabled until schema stabilizes. +- Canonical staff roles: ADMIN, MANAGER, STAFF, HOUSEKEEPING, FINANCE. +- Booking does not own rooms or money. +- Realtime events must be derived, not raw DB changes. +- Ask before touching auth or payment logic. =============================================================================== CURRENT CODEBASE UNDERSTANDING (TrisolarisServer) @@ -73,72 +42,94 @@ Repository - Scheduling enabled (@EnableScheduling) Security/Auth -- Firebase Admin auth for every request, Firebase UID required. -- Security filter verifies token and maps to MyPrincipal(userId, firebaseUid). -- Endpoints: /auth/verify and /auth/me. +- Firebase Admin auth for every request; Firebase UID required. +- /auth/verify and /auth/me. Domain entities -- Organization: id, name, emailAliases -- Property: org, code, name, addressText, timezone, currency, active, emailAddresses, otaAliases -- AppUser: org, firebaseUid, phoneE164, name, disabled -- PropertyUser: roles per property -- Role enum includes ADMIN, MANAGER, STAFF, HOUSEKEEPING, FINANCE, GUIDE, SUPERVISOR, AGENT (but only canonical roles should be used) -- RoomType: code/name/occupancy + otaAliases -- Room: roomNumber, floor, hasNfc, active, maintenance, notes -- Booking: status, source/sourceBookingId, expected check-in/out, emailAuditPdfUrl -- Guest -- RoomStay -- GuestDocument: files for guest/booking with AI-extracted data -- InboundEmail: inbound mail audit (PDF + raw eml), extractedData, status - -Repos -- Repos are under com.android.trisolarisserver.repo (note: not db.repo). -- Added repos for Booking, Guest, GuestDocument, InboundEmail. +- Organization: name, emailAliases, allowedTransportModes. +- Property: code, name, addressText, emailAddresses, otaAliases, allowedTransportModes. +- AppUser, PropertyUser (roles per property). +- RoomType: code/name/occupancy + otaAliases. +- Room: roomNumber, floor, hasNfc, active, maintenance, notes. +- Booking: status, expected check-in/out, emailAuditPdfUrl, transportMode, transportVehicleNumber. +- Guest (org-scoped). +- RoomStay. +- RoomStayChange (idempotent room move). +- IssuedCard (cardId, cardIndex, issuedAt, expiresAt, issuedBy, revokedAt). +- PropertyCardCounter (per-property cardIndex counter). +- GuestDocument (files + AI-extracted json). +- GuestVehicle (org-scoped vehicle numbers). +- InboundEmail (audit PDF + raw EML, extracted json, status). +- RoomImage (original + thumbnail). Key modules Rooms / inventory - /properties/{propertyId}/rooms - /properties/{propertyId}/rooms/board +- /properties/{propertyId}/rooms/board/stream (SSE) - /properties/{propertyId}/rooms/availability - /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 -- CRUD with otaAliases in DTOs. +- CRUD with otaAliases. -Properties/Orgs -- Property create/update accept addressText, otaAliases, emailAddresses. -- Org create/get returns emailAliases. +Properties / Orgs +- Property create/update accepts addressText, otaAliases, emailAddresses, allowedTransportModes. +- Org create/get returns emailAliases + allowedTransportModes. -Guest documents (files) -- POST /properties/{propertyId}/guests/{guestId}/documents (multipart + bookingId) -- GET list -- GET file (token or auth) -- Files stored under /home/androidlover5842/docs/{propertyId}/{guestId}/{bookingId}/ -- AI extraction via llama.cpp with strict system prompt +Booking flow +- /bookings/{id}/check-in (creates RoomStay rows) +- /bookings/{id}/check-out (closes RoomStay) +- /bookings/{id}/cancel, /no-show +- /bookings/{id}/room-stays (pre-assign RoomStay with date range) +- /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 - IMAP poller (1 min) with enable flag. -- Saves audit PDF + raw .eml to /home/androidlover5842/docs/emails -- Matches property via To/CC email first, then text aliases (name/code/address/otaAliases) -- AI extracts booking fields and creates/cancels Booking -- Booking gets emailAuditPdfUrl that points to /properties/{propertyId}/inbound-emails/{emailId}/file +- Saves audit PDF + raw .eml under /home/androidlover5842/docs/emails. +- Property match: To/CC email first; fallback to name/code/address/otaAliases. +- AI extracts booking fields; creates/cancels Booking. +- 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 -- Base URL configured per profile: - - dev: https://ai.hoteltrisolaris.in/v1/chat/completions - - prod: http://localhost:8089/v1/chat/completions -- LlamaClient uses strict system prompt: only visible text, no guessing. +- Base URL per profile: dev=https://ai.hoteltrisolaris.in/v1/chat/completions, prod=http://localhost:8089/v1/chat/completions +- LlamaClient uses strict system prompt (no guessing). - Read timeout 5 minutes. Config -- application.properties: flyway disabled, storage paths, IMAP config, token secrets. -- storage.documents.publicBaseUrl + token secret/ttl. -- storage.emails.publicBaseUrl used for booking audit URL. -- mail.imap.enabled=false by default. +- storage.documents.root=/home/androidlover5842/docs +- storage.emails.root=/home/androidlover5842/docs/emails +- storage.rooms.root=/home/androidlover5842/docs/rooms +- publicBaseUrl entries for docs/emails/rooms +- mail.imap.enabled=false by default 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. - Agents can only see free rooms. diff --git a/src/main/kotlin/com/android/trisolarisserver/controller/IssuedCards.kt b/src/main/kotlin/com/android/trisolarisserver/controller/IssuedCards.kt new file mode 100644 index 0000000..eb0293b --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/controller/IssuedCards.kt @@ -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 { + 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() + ) +} 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 48bb6d1..12ffd7d 100644 --- a/src/main/kotlin/com/android/trisolarisserver/controller/dto/BookingDtos.kt +++ b/src/main/kotlin/com/android/trisolarisserver/controller/dto/BookingDtos.kt @@ -45,3 +45,35 @@ data class RoomStayPreAssignRequest( val toAt: String, 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 +) diff --git a/src/main/kotlin/com/android/trisolarisserver/models/room/IssuedCard.kt b/src/main/kotlin/com/android/trisolarisserver/models/room/IssuedCard.kt new file mode 100644 index 0000000..2fa900b --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/models/room/IssuedCard.kt @@ -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 +) diff --git a/src/main/kotlin/com/android/trisolarisserver/models/room/PropertyCardCounter.kt b/src/main/kotlin/com/android/trisolarisserver/models/room/PropertyCardCounter.kt new file mode 100644 index 0000000..69cd1b6 --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/models/room/PropertyCardCounter.kt @@ -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() +) diff --git a/src/main/kotlin/com/android/trisolarisserver/repo/IssuedCardRepo.kt b/src/main/kotlin/com/android/trisolarisserver/repo/IssuedCardRepo.kt new file mode 100644 index 0000000..30fbdf3 --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/repo/IssuedCardRepo.kt @@ -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 { + fun findByRoomStayIdOrderByIssuedAtDesc(roomStayId: UUID): List + 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 +} diff --git a/src/main/kotlin/com/android/trisolarisserver/repo/PropertyCardCounterRepo.kt b/src/main/kotlin/com/android/trisolarisserver/repo/PropertyCardCounterRepo.kt new file mode 100644 index 0000000..a1f2374 --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/repo/PropertyCardCounterRepo.kt @@ -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 { + 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? +}