From 74ff02990e809064f8218737dee45fa18ff41794 Mon Sep 17 00:00:00 2001 From: Cavin Date: Tue, 23 Jun 2026 23:59:23 +0300 Subject: [PATCH 1/2] Add visitor endpoints --- .../domain/repository/VisitorSpecification.kt | 56 ++++++++ .../backend/service/VisitorService.kt | 134 ++++++++++++++++++ .../web/controller/VisitorController.kt | 91 ++++++++++++ .../backend/web/dto/visitor/VisitorDTOs.kt | 83 +++++++++++ 4 files changed, 364 insertions(+) create mode 100644 backend/src/main/kotlin/io/github/devcavin/backend/domain/repository/VisitorSpecification.kt create mode 100644 backend/src/main/kotlin/io/github/devcavin/backend/service/VisitorService.kt create mode 100644 backend/src/main/kotlin/io/github/devcavin/backend/web/controller/VisitorController.kt create mode 100644 backend/src/main/kotlin/io/github/devcavin/backend/web/dto/visitor/VisitorDTOs.kt diff --git a/backend/src/main/kotlin/io/github/devcavin/backend/domain/repository/VisitorSpecification.kt b/backend/src/main/kotlin/io/github/devcavin/backend/domain/repository/VisitorSpecification.kt new file mode 100644 index 0000000..71b2a9d --- /dev/null +++ b/backend/src/main/kotlin/io/github/devcavin/backend/domain/repository/VisitorSpecification.kt @@ -0,0 +1,56 @@ +package io.github.devcavin.backend.domain.repository + +import io.github.devcavin.backend.domain.model.Visitor +import io.github.devcavin.backend.web.dto.visitor.VisitorSearchParams +import jakarta.persistence.criteria.Predicate +import org.springframework.data.jpa.domain.Specification +import java.util.UUID + +object VisitorSpecification { + + fun search( + siteId: UUID, + params: VisitorSearchParams + ): Specification = Specification { root, _, cb -> + + val predicates = mutableListOf() + + predicates.add(cb.equal(root.get("site").get("id"), siteId)) + + params.name?.takeIf { it.isNotBlank() }?.let { + predicates.add( + cb.like(cb.lower(root.get("name")), "%${it.lowercase()}%") + ) + } + + params.phone?.takeIf { it.isNotBlank() }?.let { + predicates.add(cb.like(root.get("phone"), "%$it%")) + } + + params.visitorType?.takeIf { it.isNotBlank() }?.let { + predicates.add(cb.equal(root.get("visitorType"), it)) + } + + params.zoneId?.let { + predicates.add( + cb.equal(root.get("zone").get("id"), it) + ) + } + + params.status?.takeIf { it.isNotBlank() }?.let { + predicates.add( + cb.equal(root.get("visitStatus").get("name"), it) + ) + } + + params.from?.let { + predicates.add(cb.greaterThanOrEqualTo(root.get("checkInTime"), it)) + } + + params.to?.let { + predicates.add(cb.lessThanOrEqualTo(root.get("checkInTime"), it)) + } + + cb.and(*predicates.toTypedArray()) + } +} \ No newline at end of file diff --git a/backend/src/main/kotlin/io/github/devcavin/backend/service/VisitorService.kt b/backend/src/main/kotlin/io/github/devcavin/backend/service/VisitorService.kt new file mode 100644 index 0000000..79c8d37 --- /dev/null +++ b/backend/src/main/kotlin/io/github/devcavin/backend/service/VisitorService.kt @@ -0,0 +1,134 @@ +package io.github.devcavin.backend.service + +import io.github.devcavin.backend.common.exception.InvalidStateException +import io.github.devcavin.backend.common.exception.ResourceNotFoundException +import io.github.devcavin.backend.domain.model.User +import io.github.devcavin.backend.domain.model.Visitor +import io.github.devcavin.backend.domain.repository.UserRepository +import io.github.devcavin.backend.domain.repository.VisitStatusRepository +import io.github.devcavin.backend.domain.repository.VisitorRepository +import io.github.devcavin.backend.domain.repository.VisitorSpecification +import io.github.devcavin.backend.domain.repository.ZoneRepository +import io.github.devcavin.backend.web.dto.visitor.RegisterVisitorRequest +import io.github.devcavin.backend.web.dto.visitor.ReturningVisitorResponse +import io.github.devcavin.backend.web.dto.visitor.VisitorResponse +import io.github.devcavin.backend.web.dto.visitor.VisitorSearchParams +import io.github.devcavin.backend.web.dto.visitor.toResponse +import io.github.devcavin.backend.web.dto.visitor.toReturningResponse +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.security.access.AccessDeniedException +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.OffsetDateTime +import java.util.UUID + +@Service +class VisitorService( + private val visitorRepository: VisitorRepository, + private val visitStatusRepository: VisitStatusRepository, + private val zoneRepository: ZoneRepository, + private val userRepository: UserRepository +) { + + @Transactional + fun register( + requestedBy: User, + request: RegisterVisitorRequest + ): VisitorResponse { + val checkedInStatus = visitStatusRepository.findByName("CHECKED_IN") + ?: throw ResourceNotFoundException("VisitStatus", "CHECKED_IN") + + val zone = request.zoneId?.let { + zoneRepository.findById(it) + .orElseThrow { ResourceNotFoundException("Zone", it) } + .also { z -> + if (z.site.id != requestedBy.site.id) { + throw AccessDeniedException("Zone does not belong to your site") + } + } + } + + val host = request.hostId?.let { + userRepository.findById(it) + .orElseThrow { ResourceNotFoundException("User", it) } + .also { h -> + if (h.site.id != requestedBy.site.id) { + throw AccessDeniedException("Host does not belong to your site") + } + } + } + + val visitor = Visitor( + name = request.name, + phone = request.phone, + site = requestedBy.site, + zone = zone, + createdBy = requestedBy, + visitStatus = checkedInStatus, + visitorType = request.visitorType, + purpose = request.purpose + ) + + return visitorRepository.save(visitor).toResponse() + } + + @Transactional(readOnly = true) + fun getById(requestedBy: User, visitorId: UUID): VisitorResponse { + val visitor = visitorRepository.findById(visitorId) + .orElseThrow { ResourceNotFoundException("Visitor", visitorId) } + + enforcesSiteBoundary(requestedBy, visitor.site.id!!) + return visitor.toResponse() + } + + @Transactional + fun checkOut(requestedBy: User, visitorId: UUID): VisitorResponse { + val visitor = visitorRepository.findById(visitorId) + .orElseThrow { ResourceNotFoundException("Visitor", visitorId) } + + enforcesSiteBoundary(requestedBy, visitor.site.id!!) + + if (visitor.visitStatus.name != "CHECKED_IN") { + throw InvalidStateException( + "Visitor is already ${visitor.visitStatus.name.lowercase().replace('_', ' ')}" + ) + } + + val checkedOutStatus = visitStatusRepository.findByName("CHECKED_OUT") + ?: throw ResourceNotFoundException("VisitStatus", "CHECKED_OUT") + + visitor.visitStatus = checkedOutStatus + visitor.checkOutTime = OffsetDateTime.now() + + return visitorRepository.save(visitor).toResponse() + } + + @Transactional(readOnly = true) + fun search( + requestedBy: User, + params: VisitorSearchParams, + pageable: Pageable + ): Page { + val spec = VisitorSpecification.search(requestedBy.site.id!!, params) + return visitorRepository.findAll(spec, pageable).map { it.toResponse() } + } + + @Transactional(readOnly = true) + fun findReturningVisitor( + requestedBy: User, + phone: String + ): ReturningVisitorResponse? { + return visitorRepository + .findTopBySiteIdAndPhoneOrderByCheckInTimeDesc(requestedBy.site.id!!, phone) + ?.toReturningResponse() + } + + private fun enforcesSiteBoundary(requestedBy: User, visitorSiteId: UUID) { + if (requestedBy.role.name != "SUPER_ADMIN" && + requestedBy.site.id != visitorSiteId + ) { + throw AccessDeniedException("Visitor does not belong to your site") + } + } +} \ No newline at end of file diff --git a/backend/src/main/kotlin/io/github/devcavin/backend/web/controller/VisitorController.kt b/backend/src/main/kotlin/io/github/devcavin/backend/web/controller/VisitorController.kt new file mode 100644 index 0000000..ee9e7bc --- /dev/null +++ b/backend/src/main/kotlin/io/github/devcavin/backend/web/controller/VisitorController.kt @@ -0,0 +1,91 @@ +package io.github.devcavin.backend.web.controller + +import io.github.devcavin.backend.domain.model.User +import io.github.devcavin.backend.service.VisitorService +import io.github.devcavin.backend.web.dto.visitor.RegisterVisitorRequest +import io.github.devcavin.backend.web.dto.visitor.ReturningVisitorResponse +import io.github.devcavin.backend.web.dto.visitor.VisitorResponse +import io.github.devcavin.backend.web.dto.visitor.VisitorSearchParams +import jakarta.validation.Valid +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.data.web.PageableDefault +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +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.RequestParam +import org.springframework.web.bind.annotation.RestController +import java.time.OffsetDateTime +import java.util.UUID + +@RestController +@RequestMapping("/api/visitors") +class VisitorController( + private val visitorService: VisitorService +) { + + @PostMapping + fun register( + @AuthenticationPrincipal requestedBy: User, + @Valid @RequestBody request: RegisterVisitorRequest + ): ResponseEntity { + val response = visitorService.register(requestedBy, request) + return ResponseEntity.status(HttpStatus.CREATED).body(response) + } + + @GetMapping("/{id}") + fun getById( + @AuthenticationPrincipal requestedBy: User, + @PathVariable id: UUID + ): ResponseEntity { + return ResponseEntity.ok(visitorService.getById(requestedBy, id)) + } + + @PatchMapping("/{id}/checkout") + fun checkOut( + @AuthenticationPrincipal requestedBy: User, + @PathVariable id: UUID + ): ResponseEntity { + return ResponseEntity.ok(visitorService.checkOut(requestedBy, id)) + } + + @GetMapping + fun search( + @AuthenticationPrincipal requestedBy: User, + @RequestParam(required = false) name: String?, + @RequestParam(required = false) phone: String?, + @RequestParam(required = false) visitorType: String?, + @RequestParam(required = false) zoneId: UUID?, + @RequestParam(required = false) status: String?, + @RequestParam(required = false) from: OffsetDateTime?, + @RequestParam(required = false) to: OffsetDateTime?, + @PageableDefault(size = 20, sort = ["checkInTime"]) pageable: Pageable + ): ResponseEntity> { + val params = VisitorSearchParams( + name = name, + phone = phone, + visitorType = visitorType, + zoneId = zoneId, + status = status, + from = from, + to = to + ) + return ResponseEntity.ok(visitorService.search(requestedBy, params, pageable)) + } + + @GetMapping("/returning") + fun findReturning( + @AuthenticationPrincipal requestedBy: User, + @RequestParam phone: String + ): ResponseEntity { + val result = visitorService.findReturningVisitor(requestedBy, phone) + return if (result != null) ResponseEntity.ok(result) + else ResponseEntity.noContent().build() + } +} \ No newline at end of file diff --git a/backend/src/main/kotlin/io/github/devcavin/backend/web/dto/visitor/VisitorDTOs.kt b/backend/src/main/kotlin/io/github/devcavin/backend/web/dto/visitor/VisitorDTOs.kt new file mode 100644 index 0000000..5519ef9 --- /dev/null +++ b/backend/src/main/kotlin/io/github/devcavin/backend/web/dto/visitor/VisitorDTOs.kt @@ -0,0 +1,83 @@ +package io.github.devcavin.backend.web.dto.visitor + +import io.github.devcavin.backend.domain.model.Visitor +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Pattern +import jakarta.validation.constraints.Size +import java.time.OffsetDateTime +import java.util.UUID + +data class RegisterVisitorRequest( + @field:NotBlank + @field:Size(max = 100) + val name: String, + + @field:NotBlank + @field:Pattern(regexp = """^\+?[0-9\s\-]{7,25}$""", message = "Invalid phone number format") + val phone: String, + + @field:NotBlank + val visitorType: String, + + val purpose: String = "General visit", + val zoneId: UUID? = null, + val hostId: UUID? = null +) + +data class VisitorResponse( + val id: UUID, + val name: String, + val phone: String, + val visitorType: String, + val purpose: String, + val status: String, + val siteId: UUID, + val zoneId: UUID?, + val zoneName: String?, + val createdById: UUID, + val createdByName: String, + val checkInTime: OffsetDateTime, + val checkOutTime: OffsetDateTime? +) + +data class ReturningVisitorResponse( + val name: String, + val phone: String, + val visitorType: String, + val zoneId: UUID?, + val zoneName: String? +) + +data class VisitorSearchParams( + val name: String? = null, + val phone: String? = null, + val visitorType: String? = null, + val zoneId: UUID? = null, + val status: String? = null, + val from: OffsetDateTime? = null, + val to: OffsetDateTime? = null +) + +fun Visitor.toResponse() = VisitorResponse( + id = id!!, + name = name, + phone = phone, + visitorType = visitorType, + purpose = purpose, + status = visitStatus.name, + siteId = site.id!!, + zoneId = zone?.id, + zoneName = zone?.name, + createdById = createdBy.id!!, + createdByName = createdBy.name, + checkInTime = checkInTime, + checkOutTime = checkOutTime +) + +fun Visitor.toReturningResponse() = ReturningVisitorResponse( + name = name, + phone = phone, + visitorType = visitorType, + zoneId = zone?.id, + zoneName = zone?.name +) From a758c24335e8780f23d25fde198e012cd86bc30e Mon Sep 17 00:00:00 2001 From: Cavin Date: Thu, 25 Jun 2026 05:24:21 +0300 Subject: [PATCH 2/2] Refactor: Visitor search endpoint --- .../backend/domain/repository/VisitorRepository.kt | 6 +++++- .../devcavin/backend/service/VisitorService.kt | 14 +------------- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/backend/src/main/kotlin/io/github/devcavin/backend/domain/repository/VisitorRepository.kt b/backend/src/main/kotlin/io/github/devcavin/backend/domain/repository/VisitorRepository.kt index 4e59553..e2b6f82 100644 --- a/backend/src/main/kotlin/io/github/devcavin/backend/domain/repository/VisitorRepository.kt +++ b/backend/src/main/kotlin/io/github/devcavin/backend/domain/repository/VisitorRepository.kt @@ -4,7 +4,9 @@ import io.github.devcavin.backend.domain.model.VisitStatus import io.github.devcavin.backend.domain.model.Visitor import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable +import org.springframework.data.jpa.domain.Specification import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.JpaSpecificationExecutor import org.springframework.data.jpa.repository.Modifying import org.springframework.data.jpa.repository.Query import org.springframework.stereotype.Repository @@ -12,7 +14,7 @@ import java.time.OffsetDateTime import java.util.* @Repository -interface VisitorRepository : JpaRepository { +interface VisitorRepository : JpaRepository, JpaSpecificationExecutor { // returning visitor lookup by phone within a site fun findTopBySiteIdAndPhoneOrderByCheckInTimeDesc( siteId: UUID, @@ -78,4 +80,6 @@ interface VisitorRepository : JpaRepository { siteId: UUID, visitStatus: VisitStatus ): Long + + // fun findAll(specification: Specification, pageable: Pageable): Page } \ No newline at end of file diff --git a/backend/src/main/kotlin/io/github/devcavin/backend/service/VisitorService.kt b/backend/src/main/kotlin/io/github/devcavin/backend/service/VisitorService.kt index 79c8d37..10c6836 100644 --- a/backend/src/main/kotlin/io/github/devcavin/backend/service/VisitorService.kt +++ b/backend/src/main/kotlin/io/github/devcavin/backend/service/VisitorService.kt @@ -4,7 +4,6 @@ import io.github.devcavin.backend.common.exception.InvalidStateException import io.github.devcavin.backend.common.exception.ResourceNotFoundException import io.github.devcavin.backend.domain.model.User import io.github.devcavin.backend.domain.model.Visitor -import io.github.devcavin.backend.domain.repository.UserRepository import io.github.devcavin.backend.domain.repository.VisitStatusRepository import io.github.devcavin.backend.domain.repository.VisitorRepository import io.github.devcavin.backend.domain.repository.VisitorSpecification @@ -27,8 +26,7 @@ import java.util.UUID class VisitorService( private val visitorRepository: VisitorRepository, private val visitStatusRepository: VisitStatusRepository, - private val zoneRepository: ZoneRepository, - private val userRepository: UserRepository + private val zoneRepository: ZoneRepository ) { @Transactional @@ -49,16 +47,6 @@ class VisitorService( } } - val host = request.hostId?.let { - userRepository.findById(it) - .orElseThrow { ResourceNotFoundException("User", it) } - .also { h -> - if (h.site.id != requestedBy.site.id) { - throw AccessDeniedException("Host does not belong to your site") - } - } - } - val visitor = Visitor( name = request.name, phone = request.phone,