From 8793e42e617dcf962a1acfc81a63c1ea5a0bd4d6 Mon Sep 17 00:00:00 2001 From: Cavin Date: Wed, 19 Aug 2026 23:46:50 +0300 Subject: [PATCH 1/2] ref: [site, visitor and zone] domains --- .../gatelog/backend/sites/SiteController.kt | 5 +- .../gatelog/backend/sites/SiteService.kt | 11 +- .../gatelog/backend/visitors/Visitor.kt | 14 +- .../backend/visitors/VisitorProfile.kt | 10 +- .../visitors/VisitorProfileController.kt | 48 +++++ .../visitors/VisitorProfileRepository.kt | 16 +- .../backend/visitors/VisitorProfileService.kt | 106 ++++++++++ .../visitors/VisitorRegistrationService.kt | 177 +++++++++++++++++ .../backend/visitors/VisitorRepository.kt | 124 +----------- .../backend/visitors/VisitorService.kt | 181 ++++-------------- .../backend/visitors/dto/VisitorRequests.kt | 2 +- .../backend/visitors/dto/VisitorResponses.kt | 61 ++++-- .../gatelog/backend/zones/ZoneController.kt | 14 +- .../gatelog/backend/zones/ZoneRepository.kt | 1 - .../gatelog/backend/zones/ZoneService.kt | 36 +++- 15 files changed, 504 insertions(+), 302 deletions(-) create mode 100644 backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorProfileController.kt create mode 100644 backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorProfileService.kt create mode 100644 backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorRegistrationService.kt diff --git a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/sites/SiteController.kt b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/sites/SiteController.kt index 10de84b..9bdb589 100644 --- a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/sites/SiteController.kt +++ b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/sites/SiteController.kt @@ -2,10 +2,12 @@ package io.github.devcavin.gatelog.backend.sites import io.github.devcavin.gatelog.backend.sites.dto.SiteRequest import io.github.devcavin.gatelog.backend.sites.dto.SiteResponse +import io.github.devcavin.gatelog.backend.users.User import jakarta.validation.Valid import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.* import java.util.* @@ -30,9 +32,10 @@ class SiteController( @GetMapping("/{id}") @PreAuthorize("hasAnyRole('SUPER_ADMIN', 'MANAGER')") fun getById( + @AuthenticationPrincipal requestedBy: User, @PathVariable id: UUID ): ResponseEntity = - ResponseEntity.ok(siteService.getById(id)) + ResponseEntity.ok(siteService.getById(requestedBy, id)) @PutMapping("/{id}") @PreAuthorize("hasRole('SUPER_ADMIN')") diff --git a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/sites/SiteService.kt b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/sites/SiteService.kt index a9cf222..31ba15c 100644 --- a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/sites/SiteService.kt +++ b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/sites/SiteService.kt @@ -1,18 +1,21 @@ package io.github.devcavin.gatelog.backend.sites +import io.github.devcavin.gatelog.backend.auth.AuthorizationService import io.github.devcavin.gatelog.backend.common.exception.ConflictException import io.github.devcavin.gatelog.backend.common.exception.ResourceNotFoundException import io.github.devcavin.gatelog.backend.sites.dto.SiteRequest import io.github.devcavin.gatelog.backend.sites.dto.SiteResponse import io.github.devcavin.gatelog.backend.sites.dto.toEntity import io.github.devcavin.gatelog.backend.sites.dto.toResponse +import io.github.devcavin.gatelog.backend.users.User import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.util.UUID @Service class SiteService( - private val siteRepository: SiteRepository + private val siteRepository: SiteRepository, + private val authorizationService: AuthorizationService ) { @Transactional fun create(request: SiteRequest): SiteResponse { @@ -29,7 +32,11 @@ class SiteService( fun getAll(): List = siteRepository.findAll().map { it.toResponse() } @Transactional(readOnly = true) - fun getById(id: UUID): SiteResponse { + fun getById(requestBy: User, id: UUID): SiteResponse { + + // site access scope check + authorizationService.assertCovers(requestBy, id) + val site = siteRepository.findById(id) .orElseThrow { ResourceNotFoundException("Site", id) } return site.toResponse() diff --git a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/Visitor.kt b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/Visitor.kt index f1cb956..02b5536 100644 --- a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/Visitor.kt +++ b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/Visitor.kt @@ -1,5 +1,6 @@ package io.github.devcavin.gatelog.backend.visitors +import io.github.devcavin.gatelog.backend.common.exception.InvalidStateException import io.github.devcavin.gatelog.backend.common.persistence.BaseEntity import io.github.devcavin.gatelog.backend.sites.Site import io.github.devcavin.gatelog.backend.zones.Zone @@ -62,4 +63,15 @@ class Visitor( @Column(name = "check_out_time") var checkOutTime: OffsetDateTime? = null -) : BaseEntity() \ No newline at end of file +) : BaseEntity() { + fun checkOut(now: OffsetDateTime = OffsetDateTime.now()) { + if (visitStatus != VisitStatus.CHECKED_IN) { + throw InvalidStateException( + "Visitor is not currently checked in" + ) + } + + visitStatus = VisitStatus.CHECKED_OUT + checkOutTime = now + } +} \ No newline at end of file diff --git a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorProfile.kt b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorProfile.kt index 671c16f..641cf5d 100644 --- a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorProfile.kt +++ b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorProfile.kt @@ -20,10 +20,12 @@ import java.util.UUID @Entity @Table( name = "visitor_profiles", - uniqueConstraints = [UniqueConstraint( - name = "uq_visitor_profiles_phone_site", - columnNames = ["phone_number", "site_id"] - )] + uniqueConstraints = [ + UniqueConstraint( + name = "uk_visitor_profile_site_phone", + columnNames = ["site_id", "phone_number"] + ) + ] ) class VisitorProfile( @Id diff --git a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorProfileController.kt b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorProfileController.kt new file mode 100644 index 0000000..b0443b0 --- /dev/null +++ b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorProfileController.kt @@ -0,0 +1,48 @@ +package io.github.devcavin.gatelog.backend.visitors + +import io.github.devcavin.gatelog.backend.users.User +import io.github.devcavin.gatelog.backend.visitors.dto.UpdateVisitorProfileRequest +import io.github.devcavin.gatelog.backend.visitors.dto.VisitorProfileResponse +import jakarta.validation.Valid +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.PathVariable +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import java.util.UUID + +@RestController +@RequestMapping("/api/visitor-profiles") +class VisitorProfileController( + private val visitorProfileService: VisitorProfileService +) { + + @GetMapping("/{profileId}") + fun getById( + @AuthenticationPrincipal requestedBy: User, + @PathVariable profileId: UUID + ): ResponseEntity = + ResponseEntity.ok( + visitorProfileService.getById( + requestedBy, + profileId + ) + ) + + @PutMapping("/{profileId}") + fun update( + @AuthenticationPrincipal requestedBy: User, + @PathVariable profileId: UUID, + @Valid @RequestBody request: UpdateVisitorProfileRequest + ): ResponseEntity = + ResponseEntity.ok( + visitorProfileService.update( + requestedBy, + profileId, + request + ) + ) +} \ No newline at end of file diff --git a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorProfileRepository.kt b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorProfileRepository.kt index a58a881..5fa5340 100644 --- a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorProfileRepository.kt +++ b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorProfileRepository.kt @@ -4,8 +4,16 @@ import org.springframework.data.jpa.repository.JpaRepository import org.springframework.stereotype.Repository import java.util.UUID -@Repository -interface VisitorProfileRepository : JpaRepository { - fun findBySiteIdAndPhoneNumber(siteId: UUID, phoneNumber: String): VisitorProfile? - fun existsBySiteIdAndPhoneNumber(siteId: UUID, phoneNumber: String): Boolean +interface VisitorProfileRepository : + JpaRepository { + + fun findBySiteIdAndPhoneNumber( + siteId: UUID, + phoneNumber: String + ): VisitorProfile? + + fun existsBySiteIdAndPhoneNumber( + siteId: UUID, + phoneNumber: String + ): Boolean } \ No newline at end of file diff --git a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorProfileService.kt b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorProfileService.kt new file mode 100644 index 0000000..2321caa --- /dev/null +++ b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorProfileService.kt @@ -0,0 +1,106 @@ +package io.github.devcavin.gatelog.backend.visitors + +import io.github.devcavin.gatelog.backend.auth.AuthorizationService +import io.github.devcavin.gatelog.backend.common.exception.ConflictException +import io.github.devcavin.gatelog.backend.common.exception.ResourceNotFoundException +import io.github.devcavin.gatelog.backend.sites.Site +import io.github.devcavin.gatelog.backend.users.User +import io.github.devcavin.gatelog.backend.visitors.dto.UpdateVisitorProfileRequest +import io.github.devcavin.gatelog.backend.visitors.dto.VisitorProfileResponse +import io.github.devcavin.gatelog.backend.visitors.dto.toResponse +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.util.UUID + +@Service +class VisitorProfileService( + private val visitorProfileRepository: VisitorProfileRepository, + private val visitorRepository: VisitorRepository, + private val authorizationService: AuthorizationService +) { + + @Transactional(readOnly = true) + fun getById( + requestedBy: User, + profileId: UUID + ): VisitorProfileResponse { + + val profile = visitorProfileRepository.findById(profileId) + .orElseThrow { + ResourceNotFoundException( + "VisitorProfile", + profileId + ) + } + + authorizationService.assertCovers( + requestedBy, + requireNotNull(profile.site.id) + ) + + val visitCount = + visitorRepository.countByVisitorProfileId(profileId) + + return profile.toResponse(visitCount) + } + + @Transactional + fun update( + requestedBy: User, + profileId: UUID, + request: UpdateVisitorProfileRequest + ): VisitorProfileResponse { + + val profile = visitorProfileRepository.findById(profileId) + .orElseThrow { + ResourceNotFoundException( + "VisitorProfile", + profileId + ) + } + + val siteId = requireNotNull(profile.site.id) + + authorizationService.assertCovers( + requestedBy, + siteId + ) + + if ( + request.phoneNumber != profile.phoneNumber && + visitorProfileRepository.existsBySiteIdAndPhoneNumber( + siteId, + request.phoneNumber + ) + ) { + throw ConflictException( + "Phone number is already registered at this site" + ) + } + + profile.name = request.name + profile.phoneNumber = request.phoneNumber + + return visitorProfileRepository + .save(profile) + .toResponse( + visitorRepository + .countByVisitorProfileId(profileId) + ) + } + + @Transactional(readOnly = true) + fun findByPhone( + requestedBy: User, + phoneNumber: String + ): VisitorProfile? { + + } + + internal fun findOrCreate( + site: Site, + name: String, + phoneNumber: String + ): VisitorProfile { + } +} \ No newline at end of file diff --git a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorRegistrationService.kt b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorRegistrationService.kt new file mode 100644 index 0000000..9de56dd --- /dev/null +++ b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorRegistrationService.kt @@ -0,0 +1,177 @@ +package io.github.devcavin.gatelog.backend.visitors + +import io.github.devcavin.gatelog.backend.auth.AuthorizationService +import io.github.devcavin.gatelog.backend.common.exception.ResourceNotFoundException +import io.github.devcavin.gatelog.backend.sites.Site +import io.github.devcavin.gatelog.backend.sites.SiteRepository +import io.github.devcavin.gatelog.backend.users.User +import io.github.devcavin.gatelog.backend.visitors.dto.ReturningVisitorResponse +import io.github.devcavin.gatelog.backend.visitors.dto.VisitorRegistrationRequest +import io.github.devcavin.gatelog.backend.visitors.dto.VisitorResponse +import io.github.devcavin.gatelog.backend.visitors.dto.toResponse +import io.github.devcavin.gatelog.backend.zones.Zone +import io.github.devcavin.gatelog.backend.zones.ZoneRepository +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.OffsetDateTime +import java.util.UUID + +@Service +class VisitorRegistrationService( + private val visitorRepository: VisitorRepository, + private val visitorProfileRepository: VisitorProfileRepository, + private val visitStatusRepository: VisitStatusRepository, + private val visitorProfileService: VisitorProfileService, + private val siteRepository: SiteRepository, + private val zoneRepository: ZoneRepository, + private val authorizationService: AuthorizationService +) { + + @Transactional + fun register( + requestedBy: User, + request: VisitorRegistrationRequest + ): VisitorResponse { + + val siteId = requireNotNull(requestedBy.site.id) { + "Authenticated user has no site" + } + + authorizationService.assertCovers( + requestedBy, + siteId + ) + + val site = siteRepository.findById(siteId) + .orElseThrow { + ResourceNotFoundException("Site", siteId) + } + + val profile = visitorProfileRepository + .findBySiteIdAndPhoneNumber( + siteId = siteId, + phoneNumber = request.phone + ) + ?: visitorProfileRepository.save( + VisitorProfile( + name = request.name, + phoneNumber = request.phone, + site = site + ) + ) + + val zone = zoneRepository.findById(request.zoneId) + .orElseThrow { + ResourceNotFoundException( + "Zone", + request.zoneId + ) + } + + if (zone.site.id != siteId) { + throw ResourceNotFoundException( + "Zone", + request.zoneId + ) + } + + val visitStatus = visitStatusRepository + .findByName("CHECKED_IN") + ?. { + ResourceNotFoundException( + "VisitStatus", + "CHECKED_IN" + ) + } + + val visitor = Visitor( + visitorProfile = profile, + site = site, + zone = zone, + visitorType = visitorType, + purpose = request.purpose, + visitStatus = visitStatus, + createdBy = requestedBy, + checkInTime = OffsetDateTime.now(), + id = TODO(), + name = TODO(), + phone = TODO(), + checkOutTime = TODO() + ) + + return visitorRepository + .save(visitor) + .toResponse() + } + + private fun resolveSite(requestedBy: User): Site { + val siteId = requireNotNull(requestedBy.site.id) { + "Authenticated user has no site" + } + + authorizationService.assertCovers( + requestedBy, + siteId + ) + + return siteRepository.findById(siteId) + .orElseThrow { + ResourceNotFoundException("Site", siteId) + } + } + + private fun resolveZone( + siteId: UUID, + zoneId: UUID? + ): Zone? { + + if (zoneId == null) { + return null + } + + val zone = zoneRepository.findById(zoneId) + .orElseThrow { + ResourceNotFoundException("Zone", zoneId) + } + + if (zone.site.id != siteId) { + throw ResourceNotFoundException("Zone", zoneId) + } + + return zone + } + + @Transactional(readOnly = true) + fun findReturningVisitor( + requestedBy: User, + phoneNumber: String + ): ReturningVisitorResponse? { + + val siteId = requireNotNull(requestedBy.site.id) + + authorizationService.assertCovers( + requestedBy, + siteId + ) + + val profile = + visitorProfileRepository + .findBySiteIdAndPhoneNumber( + siteId, + phoneNumber + ) + ?: return null + + val lastVisit = + visitorRepository + .findTopByVisitorProfileIdAndSiteIdOrderByCheckInTimeDesc( + requireNotNull(profile.id), + siteId + ) + + return ReturningVisitorResponse( + profile = profile.toSummary(), + lastVisit = lastVisit?.toSummary() + ) + } +} \ No newline at end of file diff --git a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorRepository.kt b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorRepository.kt index 8e23d4b..29b8d49 100644 --- a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorRepository.kt +++ b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorRepository.kt @@ -1,125 +1,19 @@ package io.github.devcavin.gatelog.backend.visitors -import org.springframework.data.domain.Page -import org.springframework.data.domain.Pageable 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, JpaSpecificationExecutor { - // returning visitor lookup by phone within a site - fun findTopBySiteIdAndPhoneOrderByCheckInTimeDesc( - siteId: UUID, - phone: String - ): Visitor? - - // active visitors on the dashboard - fun findAllBySiteIdAndVisitStatus( - siteId: UUID, - visitStatus: VisitStatus, - pageable: Pageable - ): Page - - // visitors checked in today for daily count - @Query( - """ - SELECT v FROM Visitor v - WHERE v.site.id = :siteId - AND v.checkInTime >= :startOfDay - AND v.checkInTime < :endOfDay - """ - ) - fun findAllCheckedInToday( - siteId: UUID, - startOfDay: OffsetDateTime, - endOfDay: OffsetDateTime, - pageable: Pageable - ): Page - - // overdue visitors - checked in but no checkout past threshold - @Query( - """ - SELECT v FROM Visitor v - WHERE v.site.id = :siteId - AND v.visitStatus.name = 'CHECKED_IN' - AND v.checkInTime <= :threshold - """ - ) - fun findAllOverdue( - siteId: UUID, - threshold: OffsetDateTime - ): List - - // bulk status update for overdue job - @Modifying - @Query( - """ - UPDATE Visitor v - SET v.visitStatus = :overdueStatus - WHERE v.site.id = :siteId - AND v.visitStatus.name = 'CHECKED_IN' - AND v.checkInTime <= :threshold - """ - ) - fun markOverdue( - siteId: UUID, - threshold: OffsetDateTime, - overdueStatus: VisitStatus - ): Int - - // count by status - dashboard stats - fun countBySiteIdAndVisitStatus( - siteId: UUID, - visitStatus: VisitStatus - ): Long - - fun countBySiteIdAndVisitorProfileId(siteId: UUID, profileId: UUID): Long +interface VisitorRepository : + JpaRepository, + JpaSpecificationExecutor { - @Query(""" - SELECT v FROM Visitor v - WHERE v.visitStatus = :visitStatus - """) - fun findAllByVisitStatus( - visitStatus: VisitStatus, - pageable: Pageable - ): Page - - @Query(""" - SELECT v FROM Visitor v - WHERE v.visitStatus.name = 'CHECKED_IN' - AND v.checkInTime <= :threshold - """) - fun findAllOverdueGlobal( - threshold: OffsetDateTime - ): List - - @Query(""" - SELECT COUNT(v) FROM Visitor v - WHERE v.checkInTime >= :startOfDay - AND v.checkInTime < :endOfDay - """) - fun countCheckedInTodayGlobal( - startOfDay: OffsetDateTime, - endOfDay: OffsetDateTime - ): Long - - fun countByVisitStatus(visitStatus: VisitStatus): Long - - fun countBySiteIdAndVisitStatusAndCheckOutTimeBetween( - siteId: UUID, - visitStatus: VisitStatus, - start: OffsetDateTime, - end: OffsetDateTime - ): Long + fun findTopByVisitorProfileIdAndSiteIdOrderByCheckInTimeDesc( + visitorProfileId: UUID, + siteId: UUID + ): Visitor? - fun countByVisitStatusAndCheckOutTimeBetween( - visitStatus: VisitStatus, - start: OffsetDateTime, - end: OffsetDateTime + fun countByVisitorProfileId( + visitorProfileId: UUID ): Long } \ No newline at end of file diff --git a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorService.kt b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorService.kt index 4d26005..fafe7dd 100644 --- a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorService.kt +++ b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorService.kt @@ -1,99 +1,38 @@ package io.github.devcavin.gatelog.backend.visitors import io.github.devcavin.gatelog.backend.auth.AuthorizationService -import io.github.devcavin.gatelog.backend.common.exception.ConflictException -import io.github.devcavin.gatelog.backend.common.exception.InvalidStateException import io.github.devcavin.gatelog.backend.common.exception.ResourceNotFoundException import io.github.devcavin.gatelog.backend.users.User import io.github.devcavin.gatelog.backend.visitors.dto.* -import io.github.devcavin.gatelog.backend.zones.ZoneRepository 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.* @Service class VisitorService( private val visitorRepository: VisitorRepository, - private val visitStatusRepository: VisitStatusRepository, - private val zoneRepository: ZoneRepository, - private val visitorProfileRepository: VisitorProfileRepository, private val authorizationService: AuthorizationService ) { - @Transactional - fun register(requestedBy: User, request: RegisterVisitorRequest): VisitorResponse { - val checkedInStatus = visitStatusRepository.findByName("CHECKED_IN") - ?: throw ResourceNotFoundException("VisitStatus", "CHECKED_IN") - - // registration always scoped to the registering user's own site - val registrationSiteId = requestedBy.site.id!! - - val zone = request.zoneId.let { - zoneRepository.findById(it) - .orElseThrow { ResourceNotFoundException("Zone", it) } - .also { z -> - if (z.site.id != registrationSiteId) - throw AccessDeniedException("Zone does not belong to your site") - } - } - - val profile = visitorProfileRepository - .findBySiteIdAndPhoneNumber(registrationSiteId, request.phone) - ?: visitorProfileRepository.save( - VisitorProfile( - name = request.name, - phoneNumber = request.phone, - site = requestedBy.site - ) - ) - - val visitor = Visitor( - name = request.name, - phone = request.phone, - visitorProfile = profile, - 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) } - authorizationService.assertCanAccessVisitor(requestedBy, visitor) - return visitor.toResponse() - } + fun getById( + requestedBy: User, + visitorId: UUID + ): VisitorResponse { - @Transactional - fun checkOut(requestedBy: User, visitorId: UUID): VisitorResponse { val visitor = visitorRepository.findById(visitorId) - .orElseThrow { ResourceNotFoundException("Visitor", visitorId) } - - authorizationService.assertCanAccessVisitor(requestedBy, visitor) - - if (visitor.visitStatus.name != "CHECKED_IN") { - throw InvalidStateException( - "Visitor is already ${visitor.visitStatus.name - .lowercase().replace('_', ' ')}" - ) - } + .orElseThrow { + ResourceNotFoundException("Visitor", visitorId) + } - val checkedOutStatus = visitStatusRepository.findByName("CHECKED_OUT") - ?: throw ResourceNotFoundException("VisitStatus", "CHECKED_OUT") + authorizationService.assertCanAccessVisitor( + requestedBy, + visitor + ) - visitor.visitStatus = checkedOutStatus - visitor.checkOutTime = OffsetDateTime.now() - return visitorRepository.save(visitor).toResponse() + return visitor.toResponse() } @Transactional(readOnly = true) @@ -102,89 +41,37 @@ class VisitorService( params: VisitorSearchParams, pageable: Pageable ): Page { - val scope = authorizationService.scopeFor(requestedBy) - val spec = VisitorSpecification.search(scope, params) - return visitorRepository.findAll(spec, pageable).map { it.toResponse() } - } - @Transactional(readOnly = true) - fun findReturningVisitor( - requestedBy: User, - phone: String - ): ReturningVisitorResponse? { - // returning visitor lookup is always site-scoped - // even SUPER_ADMIN registers visitors at their own site - val siteId = requestedBy.site.id!! - - val profile = visitorProfileRepository - .findBySiteIdAndPhoneNumber(siteId, phone) - ?: return null - - val lastVisit = visitorRepository - .findTopBySiteIdAndPhoneOrderByCheckInTimeDesc(siteId, phone) + val scope = authorizationService.scopeFor(requestedBy) - return ReturningVisitorResponse( - name = profile.name, - phone = profile.phoneNumber, - visitorType = lastVisit?.visitorType ?: "", - zoneId = lastVisit?.zone?.id, - zoneName = lastVisit?.zone?.name - ) + return visitorRepository + .findAll( + VisitorSpecification.search(scope, params), + pageable + ) + .map(Visitor::toResponse) } @Transactional - fun updateProfile( + fun checkOut( requestedBy: User, - profileId: UUID, - request: UpdateVisitorProfileRequest - ): VisitorProfileResponse { - val profile = visitorProfileRepository.findById(profileId) - .orElseThrow { ResourceNotFoundException("VisitorProfile", profileId) } - - // profile updates always scoped to the user's own site - if (profile.site.id != requestedBy.site.id) { - throw AccessDeniedException("Profile does not belong to your site") - } + visitorId: UUID + ): VisitorResponse { - if (request.phoneNumber != profile.phoneNumber && - visitorProfileRepository.existsBySiteIdAndPhoneNumber( - requestedBy.site.id!!, request.phoneNumber - ) - ) { - throw ConflictException( - "Phone '${request.phoneNumber}' already registered at this site" - ) - } + val visitor = visitorRepository.findById(visitorId) + .orElseThrow { + ResourceNotFoundException("Visitor", visitorId) + } - profile.name = request.name - profile.phoneNumber = request.phoneNumber + authorizationService.assertCanAccessVisitor( + requestedBy, + visitor + ) - val saved = visitorProfileRepository.save(profile) - val visitCount = visitorRepository - .countBySiteIdAndVisitorProfileId(requestedBy.site.id!!, profileId) + visitor.checkOut() - return VisitorProfileResponse( - id = saved.id!!, - name = saved.name, - phoneNumber = saved.phoneNumber, - siteId = saved.site.id!!, - visitCount = visitCount.toInt() - ) + return visitorRepository + .save(visitor) + .toResponse() } - - private 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 - ) } \ No newline at end of file diff --git a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/dto/VisitorRequests.kt b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/dto/VisitorRequests.kt index 7f18939..901812c 100644 --- a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/dto/VisitorRequests.kt +++ b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/dto/VisitorRequests.kt @@ -6,7 +6,7 @@ import jakarta.validation.constraints.Size import java.time.OffsetDateTime import java.util.UUID -data class RegisterVisitorRequest( +data class VisitorRegistrationRequest( @field:NotBlank @field:Size(max = 100) val name: String, diff --git a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/dto/VisitorResponses.kt b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/dto/VisitorResponses.kt index 04fc818..020bc75 100644 --- a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/dto/VisitorResponses.kt +++ b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/dto/VisitorResponses.kt @@ -1,13 +1,19 @@ package io.github.devcavin.gatelog.backend.visitors.dto import io.github.devcavin.gatelog.backend.visitors.Visitor +import io.github.devcavin.gatelog.backend.visitors.VisitorProfile import java.time.OffsetDateTime -import java.util.UUID +import java.util.* -data class VisitorResponse( +data class VisitorProfileSummary( val id: UUID, val name: String, - val phone: String, + val phoneNumber: String +) + +data class VisitorResponse( + val id: UUID, + val profile: VisitorProfileSummary, val visitorType: String, val purpose: String, val status: String, @@ -20,34 +26,55 @@ data class VisitorResponse( val checkOutTime: OffsetDateTime? ) -data class ReturningVisitorResponse( - val name: String, - val phone: String, - val visitorType: String, - val zoneId: UUID?, - val zoneName: String? -) - data class VisitorProfileResponse( val id: UUID, val name: String, val phoneNumber: String, val siteId: UUID, - val visitCount: Int + val visitCount: Long +) + +data class ReturningVisitorResponse( + val profile: VisitorProfileSummary, + val lastVisit: VisitSummary? +) + +data class VisitSummary( + val id: UUID, + val visitorType: String, + val purpose: String, + val status: String, + val zoneId: UUID?, + val zoneName: String?, + val checkInTime: OffsetDateTime, + val checkOutTime: OffsetDateTime? ) fun Visitor.toResponse() = VisitorResponse( - id = id!!, - name = name, - phone = phone, + id = requireNotNull(id), + profile = VisitorProfileSummary( + id = requireNotNull(visitorProfile!!.id), + name = visitorProfile!!.name, + phoneNumber = visitorProfile!!.phoneNumber + ), visitorType = visitorType, purpose = purpose, status = visitStatus.name, - siteId = site.id!!, + siteId = requireNotNull(site.id), zoneId = zone?.id, zoneName = zone?.name, - createdById = createdBy.id!!, + createdById = requireNotNull(createdBy.id), createdByName = createdBy.name, checkInTime = checkInTime, checkOutTime = checkOutTime +) + +fun VisitorProfile.toResponse( + visitCount: Long +) = VisitorProfileResponse( + id = requireNotNull(id), + name = name, + phoneNumber = phoneNumber, + siteId = requireNotNull(site.id), + visitCount = visitCount ) \ No newline at end of file diff --git a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/zones/ZoneController.kt b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/zones/ZoneController.kt index ebe9924..69a4fe9 100644 --- a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/zones/ZoneController.kt +++ b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/zones/ZoneController.kt @@ -1,11 +1,13 @@ package io.github.devcavin.gatelog.backend.zones +import io.github.devcavin.gatelog.backend.users.User import io.github.devcavin.gatelog.backend.zones.dto.ZoneRequest import io.github.devcavin.gatelog.backend.zones.dto.ZoneResponse import jakarta.validation.Valid import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable @@ -25,35 +27,39 @@ class ZoneController( @PostMapping @PreAuthorize("hasAnyRole('SUPER_ADMIN', 'MANAGER')") fun create( + @AuthenticationPrincipal requestedBy: User, @PathVariable siteId: UUID, @Valid @RequestBody request: ZoneRequest ): ResponseEntity = ResponseEntity.status(HttpStatus.CREATED) - .body(zoneService.create(siteId, request)) + .body(zoneService.create(requestedBy, siteId, request)) @GetMapping @PreAuthorize("hasAnyRole('SUPER_ADMIN', 'MANAGER', 'STAFF')") fun getAllBySite( + @AuthenticationPrincipal requestedBy: User, @PathVariable siteId: UUID ): ResponseEntity> = - ResponseEntity.ok(zoneService.getAllBySite(siteId)) + ResponseEntity.ok(zoneService.getAllBySite(requestedBy, siteId)) @PutMapping("/{zoneId}") @PreAuthorize("hasAnyRole('SUPER_ADMIN', 'MANAGER')") fun update( + @AuthenticationPrincipal requestedBy: User, @PathVariable siteId: UUID, @PathVariable zoneId: UUID, @Valid @RequestBody request: ZoneRequest ): ResponseEntity = - ResponseEntity.ok(zoneService.update(siteId, zoneId, request)) + ResponseEntity.ok(zoneService.update(requestedBy, siteId, zoneId, request)) @DeleteMapping("/{zoneId}") @PreAuthorize("hasAnyRole('SUPER_ADMIN', 'MANAGER')") fun delete( + @AuthenticationPrincipal requestedBy: User, @PathVariable siteId: UUID, @PathVariable zoneId: UUID ): ResponseEntity { - zoneService.delete(siteId, zoneId) + zoneService.delete(requestedBy, siteId, zoneId) return ResponseEntity.noContent().build() } } \ No newline at end of file diff --git a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/zones/ZoneRepository.kt b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/zones/ZoneRepository.kt index fd7d712..412db47 100644 --- a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/zones/ZoneRepository.kt +++ b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/zones/ZoneRepository.kt @@ -8,5 +8,4 @@ import java.util.UUID interface ZoneRepository : JpaRepository { fun findAllBySiteId(siteId: UUID): List fun existsBySiteIdAndName(siteId: UUID, name: String): Boolean - fun findBySiteIdAndName(siteId: UUID, name: String): Zone? } \ No newline at end of file diff --git a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/zones/ZoneService.kt b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/zones/ZoneService.kt index 043b7da..2d42d04 100644 --- a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/zones/ZoneService.kt +++ b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/zones/ZoneService.kt @@ -1,8 +1,10 @@ package io.github.devcavin.gatelog.backend.zones +import io.github.devcavin.gatelog.backend.auth.AuthorizationService import io.github.devcavin.gatelog.backend.common.exception.ConflictException import io.github.devcavin.gatelog.backend.common.exception.ResourceNotFoundException import io.github.devcavin.gatelog.backend.sites.SiteRepository +import io.github.devcavin.gatelog.backend.users.User import io.github.devcavin.gatelog.backend.zones.dto.ZoneRequest import io.github.devcavin.gatelog.backend.zones.dto.ZoneResponse import io.github.devcavin.gatelog.backend.zones.dto.toResponse @@ -13,10 +15,17 @@ import java.util.UUID @Service class ZoneService( private val zoneRepository: ZoneRepository, - private val siteRepository: SiteRepository + private val siteRepository: SiteRepository, + private val authorizationService: AuthorizationService ) { @Transactional - fun create(siteId: UUID, request: ZoneRequest): ZoneResponse { + fun create( + requestedBy: User, + siteId: UUID, + request: ZoneRequest + ): ZoneResponse { + authorizationService.assertCovers(requestedBy, siteId) + val site = siteRepository.findById(siteId) .orElseThrow { ResourceNotFoundException("Site", siteId) } @@ -33,7 +42,12 @@ class ZoneService( } @Transactional(readOnly = true) - fun getAllBySite(siteId: UUID): List { + fun getAllBySite( + requestedBy: User, + siteId: UUID + ): List { + authorizationService.assertCovers(requestedBy, siteId) + if (!siteRepository.existsById(siteId)) { throw ResourceNotFoundException("Site", siteId) } @@ -42,7 +56,14 @@ class ZoneService( } @Transactional - fun update(siteId: UUID, zoneId: UUID, request: ZoneRequest): ZoneResponse { + fun update( + requestedBy: User, + siteId: UUID, + zoneId: UUID, + request: ZoneRequest + ): ZoneResponse { + authorizationService.assertCovers(requestedBy, siteId) + val zone = zoneRepository.findById(zoneId).orElseThrow { ResourceNotFoundException("Zone", zoneId) } if (zone.site.id != siteId) throw ResourceNotFoundException("Zone", zoneId) @@ -56,7 +77,12 @@ class ZoneService( } @Transactional - fun delete(zoneId: UUID, siteId: UUID) { + fun delete( + requestedBy: User, + siteId: UUID, + zoneId: UUID) { + authorizationService.assertCovers(requestedBy, siteId) + val zone = zoneRepository.findById(zoneId) .orElseThrow { ResourceNotFoundException("Zone", zoneId) } From 8018865a7a98d8473dba307675e24db03939ad49 Mon Sep 17 00:00:00 2001 From: Cavin Date: Thu, 20 Aug 2026 13:56:06 +0300 Subject: [PATCH 2/2] refactor(backend): strengthen authorization, scoped access, and visitor contracts --- .../backend/dashboard/DashboardService.kt | 2 +- .../gatelog/backend/reports/ReportService.kt | 2 - .../gatelog/backend/visitors/Visitor.kt | 35 +--- .../backend/visitors/VisitorController.kt | 77 +++++--- .../backend/visitors/VisitorProfile.kt | 8 +- .../visitors/VisitorProfileController.kt | 17 +- .../backend/visitors/VisitorProfileService.kt | 58 ++---- .../visitors/VisitorRegistrationService.kt | 177 ------------------ .../backend/visitors/VisitorRepository.kt | 109 ++++++++++- .../backend/visitors/VisitorService.kt | 141 +++++++++++++- .../backend/visitors/VisitorSpecification.kt | 91 +++++++-- .../backend/visitors/dto/VisitorRequests.kt | 2 +- .../backend/visitors/dto/VisitorResponses.kt | 69 ++++--- .../V9__normalize_visitors_to_profiles.sql | 85 +++++++++ 14 files changed, 518 insertions(+), 355 deletions(-) delete mode 100644 backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorRegistrationService.kt create mode 100644 backend/src/main/resources/db/migration/V9__normalize_visitors_to_profiles.sql diff --git a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/dashboard/DashboardService.kt b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/dashboard/DashboardService.kt index 3863df9..7e668d7 100644 --- a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/dashboard/DashboardService.kt +++ b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/dashboard/DashboardService.kt @@ -21,7 +21,7 @@ class DashboardService( private val visitorRepository: VisitorRepository, private val visitorStatusRepository: VisitStatusRepository, private val authorizationService: AuthorizationService, - @Value("\${gatelog.scheduler.overdue-threshold-hours:2}") + @Value($$"${gatelog.scheduler.overdue-threshold-hours:2}") private val overdueThresholdHours: Long, ) { @Transactional(readOnly = true) diff --git a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/reports/ReportService.kt b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/reports/ReportService.kt index 148024a..d12e884 100644 --- a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/reports/ReportService.kt +++ b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/reports/ReportService.kt @@ -56,8 +56,6 @@ class ReportService( writer.println( csvRow( v.id.toString(), - v.name, - v.phone, v.visitorType, v.purpose, v.visitStatus.name, diff --git a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/Visitor.kt b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/Visitor.kt index 02b5536..d1e8fe9 100644 --- a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/Visitor.kt +++ b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/Visitor.kt @@ -1,10 +1,9 @@ package io.github.devcavin.gatelog.backend.visitors -import io.github.devcavin.gatelog.backend.common.exception.InvalidStateException import io.github.devcavin.gatelog.backend.common.persistence.BaseEntity import io.github.devcavin.gatelog.backend.sites.Site -import io.github.devcavin.gatelog.backend.zones.Zone import io.github.devcavin.gatelog.backend.users.User +import io.github.devcavin.gatelog.backend.zones.Zone import jakarta.persistence.Column import jakarta.persistence.Entity import jakarta.persistence.FetchType @@ -20,23 +19,16 @@ import java.util.UUID @Entity @Table(name = "visitors") class Visitor( - @Id @GeneratedValue(strategy = GenerationType.UUID) @Column(updatable = false, nullable = false) override var id: UUID? = null, - @Column(nullable = false, length = 100) - var name: String, - - @Column(nullable = false, length = 25) - var phone: String, - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "visitor_profile_id") - var visitorProfile: VisitorProfile? = null, + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "visitor_profile_id", nullable = false) + var visitorProfile: VisitorProfile, - @ManyToOne(fetch = FetchType.LAZY) + @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "site_id", nullable = false) var site: Site, @@ -44,11 +36,11 @@ class Visitor( @JoinColumn(name = "zone_id") var zone: Zone? = null, - @ManyToOne(fetch = FetchType.LAZY) + @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "created_by", nullable = false) var createdBy: User, - @ManyToOne(fetch = FetchType.LAZY) + @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "visit_status_id", nullable = false) var visitStatus: VisitStatus, @@ -63,15 +55,4 @@ class Visitor( @Column(name = "check_out_time") var checkOutTime: OffsetDateTime? = null -) : BaseEntity() { - fun checkOut(now: OffsetDateTime = OffsetDateTime.now()) { - if (visitStatus != VisitStatus.CHECKED_IN) { - throw InvalidStateException( - "Visitor is not currently checked in" - ) - } - - visitStatus = VisitStatus.CHECKED_OUT - checkOutTime = now - } -} \ No newline at end of file +) : BaseEntity() \ No newline at end of file diff --git a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorController.kt b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorController.kt index 13ffd39..d197272 100644 --- a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorController.kt +++ b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorController.kt @@ -3,8 +3,6 @@ package io.github.devcavin.gatelog.backend.visitors import io.github.devcavin.gatelog.backend.users.User import io.github.devcavin.gatelog.backend.visitors.dto.RegisterVisitorRequest import io.github.devcavin.gatelog.backend.visitors.dto.ReturningVisitorResponse -import io.github.devcavin.gatelog.backend.visitors.dto.UpdateVisitorProfileRequest -import io.github.devcavin.gatelog.backend.visitors.dto.VisitorProfileResponse import io.github.devcavin.gatelog.backend.visitors.dto.VisitorResponse import io.github.devcavin.gatelog.backend.visitors.dto.VisitorSearchParams import jakarta.validation.Valid @@ -13,13 +11,11 @@ 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.access.prepost.PreAuthorize 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.PutMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam @@ -37,26 +33,39 @@ class VisitorController( fun register( @AuthenticationPrincipal requestedBy: User, @Valid @RequestBody request: RegisterVisitorRequest - ): ResponseEntity { - val response = visitorService.register(requestedBy, request) - return ResponseEntity.status(HttpStatus.CREATED).body(response) - } + ): ResponseEntity = + ResponseEntity + .status(HttpStatus.CREATED) + .body( + visitorService.register( + requestedBy, + request + ) + ) @GetMapping("/{id}") fun getById( @AuthenticationPrincipal requestedBy: User, @PathVariable id: UUID - ): ResponseEntity { - return ResponseEntity.ok(visitorService.getById(requestedBy, id)) - } + ): ResponseEntity = + 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)) - } + ): ResponseEntity = + ResponseEntity.ok( + visitorService.checkOut( + requestedBy, + id + ) + ) @GetMapping fun search( @@ -68,8 +77,13 @@ class VisitorController( @RequestParam(required = false) status: String?, @RequestParam(required = false) from: OffsetDateTime?, @RequestParam(required = false) to: OffsetDateTime?, - @PageableDefault(size = 20, sort = ["checkInTime"]) pageable: Pageable + @PageableDefault( + size = 20, + sort = ["checkInTime"] + ) + pageable: Pageable ): ResponseEntity> { + val params = VisitorSearchParams( name = name, phone = phone, @@ -79,7 +93,14 @@ class VisitorController( from = from, to = to ) - return ResponseEntity.ok(visitorService.search(requestedBy, params, pageable)) + + return ResponseEntity.ok( + visitorService.search( + requestedBy, + params, + pageable + ) + ) } @GetMapping("/returning") @@ -87,17 +108,17 @@ class VisitorController( @AuthenticationPrincipal requestedBy: User, @RequestParam phone: String ): ResponseEntity { - val result = visitorService.findReturningVisitor(requestedBy, phone) - return if (result != null) ResponseEntity.ok(result) - else ResponseEntity.noContent().build() - } - @PutMapping("/profiles/{profileId}") - @PreAuthorize("hasAnyRole('SUPER_ADMIN', 'MANAGER', 'STAFF')") - fun updateProfile( - @AuthenticationPrincipal requestedBy: User, - @PathVariable profileId: UUID, - @Valid @RequestBody request: UpdateVisitorProfileRequest - ): ResponseEntity = - ResponseEntity.ok(visitorService.updateProfile(requestedBy, profileId, request)) + 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/gatelog/backend/visitors/VisitorProfile.kt b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorProfile.kt index 641cf5d..c11c091 100644 --- a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorProfile.kt +++ b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorProfile.kt @@ -22,8 +22,8 @@ import java.util.UUID name = "visitor_profiles", uniqueConstraints = [ UniqueConstraint( - name = "uk_visitor_profile_site_phone", - columnNames = ["site_id", "phone_number"] + name = "uq_visitor_profiles_phone_site", + columnNames = ["phone_number", "site_id"] ) ] ) @@ -33,13 +33,13 @@ class VisitorProfile( @Column(updatable = false, nullable = false) override var id: UUID? = null, - @Column(nullable = false) + @Column(nullable = false, length = 100) var name: String, @Column(nullable = false, name = "phone_number", length = 25) var phoneNumber: String, - @ManyToOne(fetch = FetchType.LAZY) + @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "site_id", nullable = false) var site: Site, diff --git a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorProfileController.kt b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorProfileController.kt index b0443b0..e81dd75 100644 --- a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorProfileController.kt +++ b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorProfileController.kt @@ -5,8 +5,8 @@ import io.github.devcavin.gatelog.backend.visitors.dto.UpdateVisitorProfileReque import io.github.devcavin.gatelog.backend.visitors.dto.VisitorProfileResponse import jakarta.validation.Valid import org.springframework.http.ResponseEntity +import org.springframework.security.access.prepost.PreAuthorize import org.springframework.security.core.annotation.AuthenticationPrincipal -import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PutMapping import org.springframework.web.bind.annotation.RequestBody @@ -20,19 +20,10 @@ class VisitorProfileController( private val visitorProfileService: VisitorProfileService ) { - @GetMapping("/{profileId}") - fun getById( - @AuthenticationPrincipal requestedBy: User, - @PathVariable profileId: UUID - ): ResponseEntity = - ResponseEntity.ok( - visitorProfileService.getById( - requestedBy, - profileId - ) - ) - @PutMapping("/{profileId}") + @PreAuthorize( + "hasAnyRole('SUPER_ADMIN', 'MANAGER', 'STAFF')" + ) fun update( @AuthenticationPrincipal requestedBy: User, @PathVariable profileId: UUID, diff --git a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorProfileService.kt b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorProfileService.kt index 2321caa..a0b8df3 100644 --- a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorProfileService.kt +++ b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorProfileService.kt @@ -3,7 +3,6 @@ package io.github.devcavin.gatelog.backend.visitors import io.github.devcavin.gatelog.backend.auth.AuthorizationService import io.github.devcavin.gatelog.backend.common.exception.ConflictException import io.github.devcavin.gatelog.backend.common.exception.ResourceNotFoundException -import io.github.devcavin.gatelog.backend.sites.Site import io.github.devcavin.gatelog.backend.users.User import io.github.devcavin.gatelog.backend.visitors.dto.UpdateVisitorProfileRequest import io.github.devcavin.gatelog.backend.visitors.dto.VisitorProfileResponse @@ -19,31 +18,6 @@ class VisitorProfileService( private val authorizationService: AuthorizationService ) { - @Transactional(readOnly = true) - fun getById( - requestedBy: User, - profileId: UUID - ): VisitorProfileResponse { - - val profile = visitorProfileRepository.findById(profileId) - .orElseThrow { - ResourceNotFoundException( - "VisitorProfile", - profileId - ) - } - - authorizationService.assertCovers( - requestedBy, - requireNotNull(profile.site.id) - ) - - val visitCount = - visitorRepository.countByVisitorProfileId(profileId) - - return profile.toResponse(visitCount) - } - @Transactional fun update( requestedBy: User, @@ -59,7 +33,9 @@ class VisitorProfileService( ) } - val siteId = requireNotNull(profile.site.id) + val siteId = requireNotNull(profile.site.id) { + "Visitor profile has no site" + } authorizationService.assertCovers( requestedBy, @@ -74,33 +50,21 @@ class VisitorProfileService( ) ) { throw ConflictException( - "Phone number is already registered at this site" + "Phone '${request.phoneNumber}' already registered at this site" ) } profile.name = request.name profile.phoneNumber = request.phoneNumber - return visitorProfileRepository - .save(profile) - .toResponse( - visitorRepository - .countByVisitorProfileId(profileId) - ) - } - - @Transactional(readOnly = true) - fun findByPhone( - requestedBy: User, - phoneNumber: String - ): VisitorProfile? { + val saved = visitorProfileRepository.save(profile) - } + val visitCount = + visitorRepository.countBySiteIdAndVisitorProfileId( + siteId, + profileId + ) - internal fun findOrCreate( - site: Site, - name: String, - phoneNumber: String - ): VisitorProfile { + return saved.toResponse(visitCount) } } \ No newline at end of file diff --git a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorRegistrationService.kt b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorRegistrationService.kt deleted file mode 100644 index 9de56dd..0000000 --- a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorRegistrationService.kt +++ /dev/null @@ -1,177 +0,0 @@ -package io.github.devcavin.gatelog.backend.visitors - -import io.github.devcavin.gatelog.backend.auth.AuthorizationService -import io.github.devcavin.gatelog.backend.common.exception.ResourceNotFoundException -import io.github.devcavin.gatelog.backend.sites.Site -import io.github.devcavin.gatelog.backend.sites.SiteRepository -import io.github.devcavin.gatelog.backend.users.User -import io.github.devcavin.gatelog.backend.visitors.dto.ReturningVisitorResponse -import io.github.devcavin.gatelog.backend.visitors.dto.VisitorRegistrationRequest -import io.github.devcavin.gatelog.backend.visitors.dto.VisitorResponse -import io.github.devcavin.gatelog.backend.visitors.dto.toResponse -import io.github.devcavin.gatelog.backend.zones.Zone -import io.github.devcavin.gatelog.backend.zones.ZoneRepository -import org.springframework.stereotype.Service -import org.springframework.transaction.annotation.Transactional -import java.time.OffsetDateTime -import java.util.UUID - -@Service -class VisitorRegistrationService( - private val visitorRepository: VisitorRepository, - private val visitorProfileRepository: VisitorProfileRepository, - private val visitStatusRepository: VisitStatusRepository, - private val visitorProfileService: VisitorProfileService, - private val siteRepository: SiteRepository, - private val zoneRepository: ZoneRepository, - private val authorizationService: AuthorizationService -) { - - @Transactional - fun register( - requestedBy: User, - request: VisitorRegistrationRequest - ): VisitorResponse { - - val siteId = requireNotNull(requestedBy.site.id) { - "Authenticated user has no site" - } - - authorizationService.assertCovers( - requestedBy, - siteId - ) - - val site = siteRepository.findById(siteId) - .orElseThrow { - ResourceNotFoundException("Site", siteId) - } - - val profile = visitorProfileRepository - .findBySiteIdAndPhoneNumber( - siteId = siteId, - phoneNumber = request.phone - ) - ?: visitorProfileRepository.save( - VisitorProfile( - name = request.name, - phoneNumber = request.phone, - site = site - ) - ) - - val zone = zoneRepository.findById(request.zoneId) - .orElseThrow { - ResourceNotFoundException( - "Zone", - request.zoneId - ) - } - - if (zone.site.id != siteId) { - throw ResourceNotFoundException( - "Zone", - request.zoneId - ) - } - - val visitStatus = visitStatusRepository - .findByName("CHECKED_IN") - ?. { - ResourceNotFoundException( - "VisitStatus", - "CHECKED_IN" - ) - } - - val visitor = Visitor( - visitorProfile = profile, - site = site, - zone = zone, - visitorType = visitorType, - purpose = request.purpose, - visitStatus = visitStatus, - createdBy = requestedBy, - checkInTime = OffsetDateTime.now(), - id = TODO(), - name = TODO(), - phone = TODO(), - checkOutTime = TODO() - ) - - return visitorRepository - .save(visitor) - .toResponse() - } - - private fun resolveSite(requestedBy: User): Site { - val siteId = requireNotNull(requestedBy.site.id) { - "Authenticated user has no site" - } - - authorizationService.assertCovers( - requestedBy, - siteId - ) - - return siteRepository.findById(siteId) - .orElseThrow { - ResourceNotFoundException("Site", siteId) - } - } - - private fun resolveZone( - siteId: UUID, - zoneId: UUID? - ): Zone? { - - if (zoneId == null) { - return null - } - - val zone = zoneRepository.findById(zoneId) - .orElseThrow { - ResourceNotFoundException("Zone", zoneId) - } - - if (zone.site.id != siteId) { - throw ResourceNotFoundException("Zone", zoneId) - } - - return zone - } - - @Transactional(readOnly = true) - fun findReturningVisitor( - requestedBy: User, - phoneNumber: String - ): ReturningVisitorResponse? { - - val siteId = requireNotNull(requestedBy.site.id) - - authorizationService.assertCovers( - requestedBy, - siteId - ) - - val profile = - visitorProfileRepository - .findBySiteIdAndPhoneNumber( - siteId, - phoneNumber - ) - ?: return null - - val lastVisit = - visitorRepository - .findTopByVisitorProfileIdAndSiteIdOrderByCheckInTimeDesc( - requireNotNull(profile.id), - siteId - ) - - return ReturningVisitorResponse( - profile = profile.toSummary(), - lastVisit = lastVisit?.toSummary() - ) - } -} \ No newline at end of file diff --git a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorRepository.kt b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorRepository.kt index 29b8d49..00a9379 100644 --- a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorRepository.kt +++ b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorRepository.kt @@ -1,19 +1,118 @@ package io.github.devcavin.gatelog.backend.visitors +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable 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, JpaSpecificationExecutor { - fun findTopByVisitorProfileIdAndSiteIdOrderByCheckInTimeDesc( - visitorProfileId: UUID, - siteId: UUID + fun findTopByVisitorProfileIdOrderByCheckInTimeDesc( + visitorProfileId: UUID ): Visitor? - fun countByVisitorProfileId( - visitorProfileId: UUID + fun countBySiteIdAndVisitorProfileId( + siteId: UUID, + profileId: UUID + ): Long + + @Modifying + @Query(""" + UPDATE Visitor v + SET v.visitStatus = :overdueStatus + WHERE v.site.id = :siteId + AND v.visitStatus.name = 'CHECKED_IN' + AND v.checkInTime <= :threshold + """) + fun markOverdue( + siteId: UUID, + threshold: OffsetDateTime, + overdueStatus: VisitStatus + ): Int + + fun countBySiteIdAndVisitStatus( + siteId: UUID, + visitStatus: VisitStatus + ): Long + + fun countByVisitStatus(visitStatus: VisitStatus): Long + + @Query(""" + SELECT v FROM Visitor v + WHERE v.site.id = :siteId + AND v.checkInTime >= :startOfDay + AND v.checkInTime < :endOfDay + """) + fun findAllCheckedInToday( + siteId: UUID, + startOfDay: OffsetDateTime, + endOfDay: OffsetDateTime, + pageable: Pageable + ): Page + + @Query(""" + SELECT COUNT(v) FROM Visitor v + WHERE v.checkInTime >= :startOfDay + AND v.checkInTime < :endOfDay + """) + fun countCheckedInTodayGlobal( + startOfDay: OffsetDateTime, + endOfDay: OffsetDateTime ): Long + + fun countBySiteIdAndVisitStatusAndCheckOutTimeBetween( + siteId: UUID, + visitStatus: VisitStatus, + start: OffsetDateTime, + end: OffsetDateTime + ): Long + + fun countByVisitStatusAndCheckOutTimeBetween( + visitStatus: VisitStatus, + start: OffsetDateTime, + end: OffsetDateTime + ): Long + + fun findAllBySiteIdAndVisitStatus( + siteId: UUID, + visitStatus: VisitStatus, + pageable: Pageable + ): Page + + @Query(""" + SELECT v FROM Visitor v + WHERE v.site.id = :siteId + AND v.visitStatus.name = 'CHECKED_IN' + AND v.checkInTime <= :threshold + """) + fun findAllOverdue( + siteId: UUID, + threshold: OffsetDateTime + ): List + + @Query(""" + SELECT v FROM Visitor v + WHERE v.visitStatus = :visitStatus + """) + fun findAllByVisitStatus( + visitStatus: VisitStatus, + pageable: Pageable + ): Page + + @Query(""" + SELECT v FROM Visitor v + WHERE v.visitStatus.name = 'CHECKED_IN' + AND v.checkInTime <= :threshold + """) + fun findAllOverdueGlobal( + threshold: OffsetDateTime + ): List } \ No newline at end of file diff --git a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorService.kt b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorService.kt index fafe7dd..56db432 100644 --- a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorService.kt +++ b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorService.kt @@ -1,21 +1,97 @@ package io.github.devcavin.gatelog.backend.visitors import io.github.devcavin.gatelog.backend.auth.AuthorizationService +import io.github.devcavin.gatelog.backend.common.exception.InvalidStateException import io.github.devcavin.gatelog.backend.common.exception.ResourceNotFoundException import io.github.devcavin.gatelog.backend.users.User -import io.github.devcavin.gatelog.backend.visitors.dto.* +import io.github.devcavin.gatelog.backend.visitors.dto.RegisterVisitorRequest +import io.github.devcavin.gatelog.backend.visitors.dto.ReturningVisitorResponse +import io.github.devcavin.gatelog.backend.visitors.dto.VisitorResponse +import io.github.devcavin.gatelog.backend.visitors.dto.VisitorSearchParams +import io.github.devcavin.gatelog.backend.visitors.dto.VisitorProfileSummary +import io.github.devcavin.gatelog.backend.visitors.dto.toResponse +import io.github.devcavin.gatelog.backend.visitors.dto.toVisitSummary +import io.github.devcavin.gatelog.backend.zones.ZoneRepository 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.util.* +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 visitorProfileRepository: VisitorProfileRepository, private val authorizationService: AuthorizationService ) { + @Transactional + fun register( + requestedBy: User, + request: RegisterVisitorRequest + ): VisitorResponse { + + val site = requestedBy.site + val siteId = requireNotNull(site.id) { + "Authenticated user has no site" + } + + authorizationService.assertCovers( + requestedBy, + siteId + ) + + val zone = zoneRepository.findById(request.zoneId) + .orElseThrow { + ResourceNotFoundException("Zone", request.zoneId) + } + + if (zone.site.id != siteId) { + throw AccessDeniedException( + "Zone does not belong to your site" + ) + } + + val profile = + visitorProfileRepository + .findBySiteIdAndPhoneNumber( + siteId, + request.phone + ) + ?: visitorProfileRepository.save( + VisitorProfile( + name = request.name, + phoneNumber = request.phone, + site = site + ) + ) + + val checkedInStatus = + visitStatusRepository.findByName("CHECKED_IN") + ?: throw ResourceNotFoundException( + "VisitStatus", + "CHECKED_IN" + ) + + val visitor = Visitor( + visitorProfile = profile, + site = 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, @@ -49,7 +125,7 @@ class VisitorService( VisitorSpecification.search(scope, params), pageable ) - .map(Visitor::toResponse) + .map { it.toResponse() } } @Transactional @@ -68,10 +144,67 @@ class VisitorService( visitor ) - visitor.checkOut() + 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 findReturningVisitor( + requestedBy: User, + phone: String + ): ReturningVisitorResponse? { + + val siteId = requireNotNull(requestedBy.site.id) { + "Authenticated user has no site" + } + + authorizationService.assertCovers( + requestedBy, + siteId + ) + + val profile = + visitorProfileRepository + .findBySiteIdAndPhoneNumber( + siteId, + phone + ) + ?: return null + + val lastVisit = + visitorRepository + .findTopByVisitorProfileIdOrderByCheckInTimeDesc( + requireNotNull(profile.id) + ) + + return ReturningVisitorResponse( + profile = VisitorProfileSummary( + id = requireNotNull(profile.id), + name = profile.name, + phoneNumber = profile.phoneNumber + ), + lastVisit = lastVisit?.toVisitSummary() + ) + } } \ No newline at end of file diff --git a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorSpecification.kt b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorSpecification.kt index dfc87a3..982b25c 100644 --- a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorSpecification.kt +++ b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorSpecification.kt @@ -15,36 +15,87 @@ object VisitorSpecification { val predicates = mutableListOf() - /** - * site filter - only applied for site-scoped access - * SUPER_ADMIN with Global scope skips this entirely - */ if (scope is AccessScope.Site) { predicates.add( - cb.equal(root.get("site").get("id"), scope.siteId) + cb.equal( + root.get("site").get("id"), + scope.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)) - } + val profile = root.get("visitorProfile") + + params.name + ?.takeIf { it.isNotBlank() } + ?.let { + predicates.add( + cb.like( + cb.lower(profile.get("name")), + "%${it.lowercase()}%" + ) + ) + } + + params.phone + ?.takeIf { it.isNotBlank() } + ?.let { + predicates.add( + cb.like( + profile.get("phoneNumber"), + "%$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)) + 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)) + predicates.add( + cb.greaterThanOrEqualTo( + root.get("checkInTime"), + it + ) + ) } + params.to?.let { - predicates.add(cb.lessThanOrEqualTo(root.get("checkInTime"), it)) + predicates.add( + cb.lessThanOrEqualTo( + root.get("checkInTime"), + it + ) + ) } cb.and(*predicates.toTypedArray()) diff --git a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/dto/VisitorRequests.kt b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/dto/VisitorRequests.kt index 901812c..7f18939 100644 --- a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/dto/VisitorRequests.kt +++ b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/dto/VisitorRequests.kt @@ -6,7 +6,7 @@ import jakarta.validation.constraints.Size import java.time.OffsetDateTime import java.util.UUID -data class VisitorRegistrationRequest( +data class RegisterVisitorRequest( @field:NotBlank @field:Size(max = 100) val name: String, diff --git a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/dto/VisitorResponses.kt b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/dto/VisitorResponses.kt index 020bc75..54d853c 100644 --- a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/dto/VisitorResponses.kt +++ b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/dto/VisitorResponses.kt @@ -3,7 +3,7 @@ package io.github.devcavin.gatelog.backend.visitors.dto import io.github.devcavin.gatelog.backend.visitors.Visitor import io.github.devcavin.gatelog.backend.visitors.VisitorProfile import java.time.OffsetDateTime -import java.util.* +import java.util.UUID data class VisitorProfileSummary( val id: UUID, @@ -50,31 +50,48 @@ data class VisitSummary( val checkOutTime: OffsetDateTime? ) -fun Visitor.toResponse() = VisitorResponse( - id = requireNotNull(id), - profile = VisitorProfileSummary( - id = requireNotNull(visitorProfile!!.id), - name = visitorProfile!!.name, - phoneNumber = visitorProfile!!.phoneNumber - ), - visitorType = visitorType, - purpose = purpose, - status = visitStatus.name, - siteId = requireNotNull(site.id), - zoneId = zone?.id, - zoneName = zone?.name, - createdById = requireNotNull(createdBy.id), - createdByName = createdBy.name, - checkInTime = checkInTime, - checkOutTime = checkOutTime -) +fun Visitor.toResponse(): VisitorResponse { + val profile = visitorProfile + + return VisitorResponse( + id = requireNotNull(id), + profile = VisitorProfileSummary( + id = requireNotNull(profile.id), + name = profile.name, + phoneNumber = profile.phoneNumber + ), + visitorType = visitorType, + purpose = purpose, + status = visitStatus.name, + siteId = requireNotNull(site.id), + zoneId = zone?.id, + zoneName = zone?.name, + createdById = requireNotNull(createdBy.id), + createdByName = createdBy.name, + checkInTime = checkInTime, + checkOutTime = checkOutTime + ) +} fun VisitorProfile.toResponse( visitCount: Long -) = VisitorProfileResponse( - id = requireNotNull(id), - name = name, - phoneNumber = phoneNumber, - siteId = requireNotNull(site.id), - visitCount = visitCount -) \ No newline at end of file +): VisitorProfileResponse = + VisitorProfileResponse( + id = requireNotNull(id), + name = name, + phoneNumber = phoneNumber, + siteId = requireNotNull(site.id), + visitCount = visitCount + ) + +fun Visitor.toVisitSummary(): VisitSummary = + VisitSummary( + id = requireNotNull(id), + visitorType = visitorType, + purpose = purpose, + status = visitStatus.name, + zoneId = zone?.id, + zoneName = zone?.name, + checkInTime = checkInTime, + checkOutTime = checkOutTime + ) \ No newline at end of file diff --git a/backend/src/main/resources/db/migration/V9__normalize_visitors_to_profiles.sql b/backend/src/main/resources/db/migration/V9__normalize_visitors_to_profiles.sql new file mode 100644 index 0000000..fb617cc --- /dev/null +++ b/backend/src/main/resources/db/migration/V9__normalize_visitors_to_profiles.sql @@ -0,0 +1,85 @@ +-- 1. Create one profile for each distinct phone/site combination +-- that does not already have a profile. +-- +-- Prefer the most recently checked-in visitor's name when +-- multiple historical visitor rows have the same phone/site. + +INSERT INTO visitor_profiles ( + id, + name, + phone_number, + site_id, + created_at, + updated_at +) +SELECT + gen_random_uuid(), + source.name, + source.phone, + source.site_id, + now(), + now() +FROM ( + SELECT DISTINCT ON (v.site_id, v.phone) + v.site_id, + v.phone, + v.name + FROM visitors v + LEFT JOIN visitor_profiles p + ON p.site_id = v.site_id + AND p.phone_number = v.phone + WHERE p.id IS NULL + ORDER BY + v.site_id, + v.phone, + v.check_in_time DESC, + v.id DESC + ) source; + + +-- 2. Attach every visitor to its site-scoped profile. + +UPDATE visitors v +SET visitor_profile_id = p.id + FROM visitor_profiles p +WHERE p.site_id = v.site_id + AND p.phone_number = v.phone + AND v.visitor_profile_id IS NULL; + + +-- 3. Fail the migration if any visitor still has no profile. +-- +-- This is intentionally defensive. We should never silently +-- introduce a nullable relationship after this migration. + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM visitors + WHERE visitor_profile_id IS NULL + ) THEN + RAISE EXCEPTION + 'Cannot normalize visitors: one or more visitors have no visitor profile'; +END IF; +END $$; + + +-- 4. The relationship is now mandatory. + +ALTER TABLE visitors + ALTER COLUMN visitor_profile_id SET NOT NULL; + + +-- 5. Visitor identity is now owned exclusively by visitor_profiles. + +ALTER TABLE visitors +DROP COLUMN name; + +ALTER TABLE visitors +DROP COLUMN phone; + + +-- 6. The old visitor-phone index is no longer relevant. + +DROP INDEX IF EXISTS idx_visitors_phone; \ No newline at end of file