Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,17 @@ 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
import java.time.OffsetDateTime
import java.util.*

@Repository
interface VisitorRepository : JpaRepository<Visitor, UUID> {
interface VisitorRepository : JpaRepository<Visitor, UUID>, JpaSpecificationExecutor<Visitor> {
// returning visitor lookup by phone within a site
fun findTopBySiteIdAndPhoneOrderByCheckInTimeDesc(
siteId: UUID,
Expand Down Expand Up @@ -78,4 +80,6 @@ interface VisitorRepository : JpaRepository<Visitor, UUID> {
siteId: UUID,
visitStatus: VisitStatus
): Long

// fun findAll(specification: Specification<VisitorSpecification>, pageable: Pageable): Page<Visitor>
}
Original file line number Diff line number Diff line change
@@ -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<Visitor> = Specification { root, _, cb ->

val predicates = mutableListOf<Predicate>()

predicates.add(cb.equal(root.get<Any>("site").get<UUID>("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<String>("visitorType"), it))
}

params.zoneId?.let {
predicates.add(
cb.equal(root.get<Any>("zone").get<UUID>("id"), it)
)
}

params.status?.takeIf { it.isNotBlank() }?.let {
predicates.add(
cb.equal(root.get<Any>("visitStatus").get<String>("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())
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
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.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
) {

@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 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<VisitorResponse> {
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")
}
}
}
Original file line number Diff line number Diff line change
@@ -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<VisitorResponse> {
val response = visitorService.register(requestedBy, request)
return ResponseEntity.status(HttpStatus.CREATED).body(response)
}

@GetMapping("/{id}")
fun getById(
@AuthenticationPrincipal requestedBy: User,
@PathVariable id: UUID
): ResponseEntity<VisitorResponse> {
return ResponseEntity.ok(visitorService.getById(requestedBy, id))
}

@PatchMapping("/{id}/checkout")
fun checkOut(
@AuthenticationPrincipal requestedBy: User,
@PathVariable id: UUID
): ResponseEntity<VisitorResponse> {
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<Page<VisitorResponse>> {
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<ReturningVisitorResponse> {
val result = visitorService.findReturningVisitor(requestedBy, phone)
return if (result != null) ResponseEntity.ok(result)
else ResponseEntity.noContent().build()
}
}
Original file line number Diff line number Diff line change
@@ -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
)
Loading