diff --git a/docs/visit.md b/docs/visit.md index 16c0b6f..64949f8 100644 --- a/docs/visit.md +++ b/docs/visit.md @@ -59,3 +59,6 @@ Create Visit ``` +## Visit Status +- `OVERDUE` - A visitor who has not checked out and has exceeded the configured expected visit duration. + diff --git a/src/main/kotlin/io/github/devcavin/gatelog/auth/AccessScope.kt b/src/main/kotlin/io/github/devcavin/gatelog/auth/AccessScope.kt index be7383c..9aeecc9 100644 --- a/src/main/kotlin/io/github/devcavin/gatelog/auth/AccessScope.kt +++ b/src/main/kotlin/io/github/devcavin/gatelog/auth/AccessScope.kt @@ -5,7 +5,7 @@ import java.util.UUID /** * Represents the data visibility scope for an authenticated user. * - * GLOBAL - user can access resources across all sites (SUPER_ADMIN) + * GLOBAL - user can access resources across all sites (ADMIN) * * SITE - user can only access resources belonging to their own site (MANAGER, STAFF) * @@ -15,7 +15,7 @@ import java.util.UUID */ sealed class AccessScope { - /** No site boundary - SUPER_ADMIN sees everything */ + /** No site boundary - ADMIN sees everything */ data object Global : AccessScope() /** Restricted to a single site - MANAGER and STAFF */ @@ -27,9 +27,15 @@ sealed class AccessScope { is Site -> this.siteId == siteId } - /** Returns the siteId if site-scoped, null if global */ - val siteIdOrNull: UUID? get() = when (this) { - is Global -> null - is Site -> this.siteId - } + /** + * Returns the site boundary for this scope. + * + * Site scope returns its site ID. + * Global scope returns null because no site filter is required. + */ + val siteIdOrNull: UUID? + get() = when (this) { + is Global -> null + is Site -> siteId + } } \ No newline at end of file diff --git a/src/main/kotlin/io/github/devcavin/gatelog/auth/AuthorizationService.kt b/src/main/kotlin/io/github/devcavin/gatelog/auth/AuthorizationService.kt index 9d33247..fb81c49 100644 --- a/src/main/kotlin/io/github/devcavin/gatelog/auth/AuthorizationService.kt +++ b/src/main/kotlin/io/github/devcavin/gatelog/auth/AuthorizationService.kt @@ -1,68 +1,70 @@ package io.github.devcavin.gatelog.auth -import io.github.devcavin.gatelog.common.exception.ResourceNotFoundException import io.github.devcavin.gatelog.common.exception.AccessDeniedException +import io.github.devcavin.gatelog.common.exception.ResourceNotFoundException import io.github.devcavin.gatelog.sites.Site import io.github.devcavin.gatelog.users.User import io.github.devcavin.gatelog.visitors.Visit import org.springframework.stereotype.Service -import java.util.* +import java.util.UUID @Service class AuthorizationService { + /** * Derives the access scope for a user based on their role. * This is the single source of truth for scope decisions. */ - fun scopeFor(user: User): AccessScope { return when (user.role.name) { - "SUPER_ADMIN" -> AccessScope.Global - "MANAGER", "STAFF" -> AccessScope.Site(user.site.id!!) - else -> throw AccessDeniedException("Unsupported user role") + "ADMIN" -> AccessScope.Global + + "MANAGER", "STAFF" -> + AccessScope.Site(requireSiteId(user)) + + else -> + throw AccessDeniedException("Unsupported user role") } } /** - * Asserts the user's scope covers the given siteId. - * Throws AuthorizationDeniedException if the scope does not cover it. + * Asserts that the user's scope covers the given site. */ - - fun assertCovers(user: User, siteId: UUID) { + fun assertCovers( + user: User, + siteId: UUID + ) { if (!scopeFor(user).covers(siteId)) { throw AccessDeniedException("Authorization denied") } } /** - * Asserts the user's scope covers the visitor's site. - * Throws ResourceNotFoundException for site-scoped users seeing - * resources from another site - avoids leaking resource existence. + * Asserts that the user's scope covers the visit's site. + * + * Site-scoped users receive a not-found response when attempting + * to access a visit belonging to another site, preventing resource + * existence from being leaked. */ - - fun assertCanAccessVisitor(user: User, visitor: Visit) { - if (!scopeFor(user).covers(requireSiteId(visitor.site))) { - throw ResourceNotFoundException("Visitor", requireNotNull(visitor.id)) + fun assertCanAccessVisit( + user: User, + visit: Visit + ) { + if (!scopeFor(user).covers(requireSiteId(visit.site))) { + throw ResourceNotFoundException( + "Visit", + requireNotNull(visit.id) + ) } } - /** - * Returns a siteId filter appropriate for list/search queries. - * Global scope returns null - callers omit the filter entirely. - * Site scope returns the user's siteId - callers apply it. - */ - - fun siteFilterFor(user: User): UUID? = scopeFor(user).siteIdOrNull - - fun canAccessSite(user: User, siteId: UUID): Boolean = scopeFor(user).covers(siteId) - /** * Enforces who can create a user with the given role at the given site. - * SUPER_ADMIN - unrestricted. - * MANAGER - Staff only, at their own site. - * STAFF - cannot create users. + * + * ADMIN - unrestricted. + * MANAGER - Staff only, at their own site. + * STAFF - cannot create users. */ - fun assertCanCreateUser( requestedBy: User, targetRoleName: String, @@ -72,24 +74,33 @@ class AuthorizationService { is AccessScope.Global -> Unit is AccessScope.Site -> { - if (requestedBy.role.name != "MANAGER") - throw AccessDeniedException("Insufficient privileges to create users") - - if (targetRoleName != "STAFF") - throw AccessDeniedException("Managers can only create staff accounts") - - if (targetSiteId != scope.siteId) - throw AccessDeniedException("Managers can only create users at their own site") + if (requestedBy.role.name != "MANAGER") { + throw AccessDeniedException( + "Insufficient privileges to create users" + ) + } + + if (targetRoleName != "STAFF") { + throw AccessDeniedException( + "Managers can only create staff accounts" + ) + } + + if (targetSiteId != scope.siteId) { + throw AccessDeniedException( + "Managers can only create users at their own site" + ) + } } } } /** * Enforces who can update a user's details and which role they can assign. - * SUPER_ADMIN - unrestricted. - * MANAGER - Staff at their own site, cannot elevate beyond Staff. + * + * ADMIN - unrestricted. + * MANAGER - Staff at their own site, cannot elevate beyond Staff. */ - fun assertCanUpdateUser( requestedBy: User, target: User, @@ -99,64 +110,95 @@ class AuthorizationService { is AccessScope.Global -> Unit is AccessScope.Site -> { - if (target.site.id != scope.siteId) - throw AccessDeniedException("User does not belong to your site") - - if (target.role.name != "STAFF") - throw AccessDeniedException("Managers can only update staff accounts") - - if (newRoleName != "STAFF") - throw AccessDeniedException("Managers cannot change role beyond staff") + if (target.site.id != scope.siteId) { + throw AccessDeniedException( + "User does not belong to your site" + ) + } + + if (target.role.name != "STAFF") { + throw AccessDeniedException( + "Managers can only update staff accounts" + ) + } + + if (newRoleName != "STAFF") { + throw AccessDeniedException( + "Managers cannot change role beyond staff" + ) + } } } } /** * Enforces who can deactivate a user. - * SUPER_ADMIN - unrestricted. - * MANAGER - Staff at their own site only. + * + * ADMIN - unrestricted. + * MANAGER - Staff at their own site only. */ - - fun assertCanDeactivateUser(requestedBy: User, target: User) { + fun assertCanDeactivateUser( + requestedBy: User, + target: User + ) { when (val scope = scopeFor(requestedBy)) { - is AccessScope.Global -> Unit is AccessScope.Site -> { - if (target.site.id != scope.siteId) - throw AccessDeniedException("User does not belong to your site") - - if (target.role.name != "STAFF") - throw AccessDeniedException("Managers can only deactivate staff accounts") + if (target.site.id != scope.siteId) { + throw AccessDeniedException( + "User does not belong to your site" + ) + } + + if (target.role.name != "STAFF") { + throw AccessDeniedException( + "Managers can only deactivate staff accounts" + ) + } } } } /** - * Enforces visibility - who can see a given user record. - * SUPER_ADMIN - can see any user. - * MANAGER - Staff at their own site only. + * Enforces visibility of a user record. + * + * ADMIN - can see any user. + * MANAGER - Staff at their own site only. */ - fun assertCanViewUser(requestedBy: User, target: User) { + fun assertCanViewUser( + requestedBy: User, + target: User + ) { when (val scope = scopeFor(requestedBy)) { - is AccessScope.Global -> Unit is AccessScope.Site -> { - if (target.site.id != scope.siteId) - throw ResourceNotFoundException("User", requireNotNull(target.id)) - - if (target.role.name != "STAFF") - throw AccessDeniedException("Managers can only view staff accounts") + if (target.site.id != scope.siteId) { + throw ResourceNotFoundException( + "User", + requireNotNull(target.id) + ) + } + + if (target.role.name != "STAFF") { + throw AccessDeniedException( + "Managers can only view staff accounts" + ) + } } } } - private fun requireSiteId(user: User) : UUID { - return user.site.id ?: throw AccessDeniedException("User is not associated with a site") - } - - private fun requireSiteId(site: Site) : UUID { - return site.id ?: throw AccessDeniedException("Resource is not associated with a site") - } + private fun requireSiteId(user: User): UUID = + user.site.id + ?: throw AccessDeniedException( + "User is not associated with a site" + ) + + private fun requireSiteId(site: Site): UUID = + site.id + ?: throw AccessDeniedException( + "Resource is not associated with a site" + ) } \ No newline at end of file diff --git a/src/main/kotlin/io/github/devcavin/gatelog/common/service/BaseEntityService.kt b/src/main/kotlin/io/github/devcavin/gatelog/common/service/BaseEntityService.kt new file mode 100644 index 0000000..b889217 --- /dev/null +++ b/src/main/kotlin/io/github/devcavin/gatelog/common/service/BaseEntityService.kt @@ -0,0 +1,17 @@ +package io.github.devcavin.gatelog.common.service + +import io.github.devcavin.gatelog.common.exception.ResourceNotFoundException +import org.springframework.data.jpa.repository.JpaRepository +import java.util.UUID + +abstract class BaseEntityService( + private val repository: JpaRepository, + private val resourceName: String +) { + + protected fun findEntityById(id: UUID): T = + repository.findById(id) + .orElseThrow { + ResourceNotFoundException(resourceName, id) + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/github/devcavin/gatelog/common/time/TimeConfig.kt b/src/main/kotlin/io/github/devcavin/gatelog/common/time/TimeConfig.kt new file mode 100644 index 0000000..924411c --- /dev/null +++ b/src/main/kotlin/io/github/devcavin/gatelog/common/time/TimeConfig.kt @@ -0,0 +1,13 @@ +package io.github.devcavin.gatelog.common.time + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import java.time.Clock +import java.time.ZoneOffset + +@Configuration +class TimeConfig { + @Bean + fun clock(): Clock = + Clock.system(ZoneOffset.UTC) +} \ No newline at end of file diff --git a/src/main/kotlin/io/github/devcavin/gatelog/common/time/TimeProvider.kt b/src/main/kotlin/io/github/devcavin/gatelog/common/time/TimeProvider.kt new file mode 100644 index 0000000..3b65d4a --- /dev/null +++ b/src/main/kotlin/io/github/devcavin/gatelog/common/time/TimeProvider.kt @@ -0,0 +1,11 @@ +package io.github.devcavin.gatelog.common.time + +import org.springframework.stereotype.Component +import java.time.Clock +import java.time.OffsetDateTime + +@Component +class TimeProvider(private val clock: Clock) { + + fun timeNow() : OffsetDateTime = OffsetDateTime.now(clock) +} \ No newline at end of file diff --git a/src/main/kotlin/io/github/devcavin/gatelog/common/time/TimeUtil.kt b/src/main/kotlin/io/github/devcavin/gatelog/common/time/TimeUtil.kt new file mode 100644 index 0000000..7e8af3e --- /dev/null +++ b/src/main/kotlin/io/github/devcavin/gatelog/common/time/TimeUtil.kt @@ -0,0 +1,29 @@ +package io.github.devcavin.gatelog.common.time + +import java.time.OffsetDateTime +import java.time.ZoneOffset + +object TimeUtil { + + fun timeNow(): OffsetDateTime = + OffsetDateTime.now(ZoneOffset.UTC) + + fun startOfToday(): OffsetDateTime = + timeNow() + .toLocalDate() + .atStartOfDay() + .atOffset(ZoneOffset.UTC) + + fun startOfTomorrow(): OffsetDateTime = + startOfToday().plusDays(1) + + fun endOfToday(): OffsetDateTime = + startOfTomorrow() + + fun isOvernight( + checkInTime: OffsetDateTime, + now: OffsetDateTime = timeNow() + ): Boolean = + checkInTime.toLocalDate() < now.toLocalDate() +} + diff --git a/src/main/kotlin/io/github/devcavin/gatelog/dashboard/DashboardService.kt b/src/main/kotlin/io/github/devcavin/gatelog/dashboard/DashboardService.kt index 3c5b37a..339467d 100644 --- a/src/main/kotlin/io/github/devcavin/gatelog/dashboard/DashboardService.kt +++ b/src/main/kotlin/io/github/devcavin/gatelog/dashboard/DashboardService.kt @@ -1,112 +1,128 @@ package io.github.devcavin.gatelog.dashboard import io.github.devcavin.gatelog.auth.AuthorizationService +import io.github.devcavin.gatelog.common.exception.ResourceNotFoundException +import io.github.devcavin.gatelog.common.time.TimeUtil import io.github.devcavin.gatelog.dashboard.dto.DashboardFeed import io.github.devcavin.gatelog.dashboard.dto.DashboardSummary import io.github.devcavin.gatelog.users.User -import io.github.devcavin.gatelog.visitors.VisitStatusRepository import io.github.devcavin.gatelog.visitors.VisitRepository +import io.github.devcavin.gatelog.visitors.VisitStatusRepository import io.github.devcavin.gatelog.visitors.dto.toResponse -import org.springframework.beans.factory.annotation.Value import org.springframework.data.domain.PageRequest -import org.springframework.data.domain.Sort import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional -import java.time.OffsetDateTime -import java.time.ZoneOffset -import java.util.UUID @Service class DashboardService( private val visitRepository: VisitRepository, - private val visitorStatusRepository: VisitStatusRepository, - private val authorizationService: AuthorizationService, - @Value($$"${gatelog.scheduler.overdue-threshold-hours:2}") - private val overdueThresholdHours: Long, + private val visitStatusRepository: VisitStatusRepository, + private val authorizationService: AuthorizationService ) { + @Transactional(readOnly = true) fun getFeed(requestedBy: User): DashboardFeed { + val scope = authorizationService.scopeFor(requestedBy) - val now = OffsetDateTime.now(ZoneOffset.UTC) - val startOfDay = now.toLocalDate().atStartOfDay().atOffset(ZoneOffset.UTC) - val endOfDay = startOfDay.plusDays(1) - val overdueThreshold = now.minusHours(overdueThresholdHours) - - val checkedInStatus = visitorStatusRepository.findByName("CHECKED_IN")!! - val checkedOutStatus = visitorStatusRepository.findByName("CHECKED_OUT")!! - val overdueStatus = visitorStatusRepository.findByName("OVERDUE")!! - - // siteId is null for SUPER_ADMIN (Global scope) - // siteId is set for MANAGER and STAFF (Site scope) - val siteId: UUID? = scope.siteIdOrNull - - val currentlyOnPremises = if (siteId != null) { - visitRepository.countBySiteIdAndVisitStatus(siteId, checkedInStatus) + visitRepository.countBySiteIdAndVisitStatus(siteId, overdueStatus) - } else { - visitRepository.countByVisitStatus(checkedInStatus) + visitRepository.countByVisitStatus(overdueStatus) - } - - val checkedInToday = if (siteId != null) { - visitRepository.findAllCheckedInToday( - siteId, startOfDay, endOfDay, PageRequest.of(0, 1) - ).totalElements - } else { - visitRepository.countCheckedInTodayGlobal(startOfDay, endOfDay) - } - - val checkedOutToday = if (siteId != null) { - visitRepository.countBySiteIdAndVisitStatusAndCheckOutTimeBetween(siteId, checkedOutStatus, startOfDay, endOfDay) - } else { - visitRepository.countByVisitStatusAndCheckOutTimeBetween(checkedOutStatus, startOfDay, endOfDay) - } - - val overdueCount = if (siteId != null) { - visitRepository.findAllBySiteIdAndVisitStatus( - siteId, overdueStatus, PageRequest.of(0, 1) - ).totalElements - } else { - visitRepository.countByVisitStatus(overdueStatus) - } - - val activeVisitors = if (siteId != null) { - visitRepository.findAllBySiteIdAndVisitStatus( - siteId, checkedInStatus, - PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "checkInTime")) + val siteId = scope.siteIdOrNull + + val startOfToday = TimeUtil.startOfToday() + val endOfTheDay = TimeUtil.endOfToday() + + val checkedInStatus = + visitStatusRepository.findByName("CHECKED_IN") + ?: throw ResourceNotFoundException( + "Visit Status", + "CHECKED_IN" + ) + + val checkedOutStatus = + visitStatusRepository.findByName("CHECKED_OUT") + ?: throw ResourceNotFoundException( + "Visit Status", + "CHECKED_OUT" + ) + + val overdueStatus = + visitStatusRepository.findByName("OVERDUE") + ?: throw ResourceNotFoundException( + "Visit Status", + "OVERDUE" + ) + + val currentlyOnPremises = + visitRepository.countCurrentlyOnPremises( + siteId = siteId, + checkedInStatus = checkedInStatus, + overdueStatus = overdueStatus + ) + + val checkedInToday = + visitRepository.countCheckedInToday( + siteId = siteId, + startOfDay = startOfToday, + endOfDay = endOfTheDay + ) + + val checkedOutToday = + visitRepository.countCheckedOutToday( + siteId = siteId, + checkedOutStatus = checkedOutStatus, + startOfDay = startOfToday, + endOfDay = endOfTheDay + ) + + val overdueCount = + visitRepository.countOverdue( + siteId = siteId, + overdueStatus = overdueStatus + ) + + val overnightCount = + visitRepository.countOvernight( + siteId = siteId, + startOfToday = startOfToday + ) + + val activeVisitors = + visitRepository.findActiveVisitors( + siteId = siteId, + checkedInStatus = checkedInStatus, + pageable = PageRequest.of(0, 10) ).content - } else { - visitRepository.findAllByVisitStatus( - checkedInStatus, - PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "checkInTime")) + + val overdueVisitors = + visitRepository.findOverdueVisitors( + siteId = siteId, + overdueStatus = overdueStatus, + pageable = PageRequest.of(0, 10) ).content - } - - val overdueVisitors = if (siteId != null) { - visitRepository.findAllOverdue(siteId, overdueThreshold) - } else { - visitRepository.findAllOverdueGlobal(overdueThreshold) - } - - val recentlyCheckedOut = if (siteId != null) { - visitRepository.findAllBySiteIdAndVisitStatus( - siteId, checkedOutStatus, - PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "checkOutTime")) + + val overnightVisitors = + visitRepository.findOvernightVisitors( + siteId = siteId, + startOfToday = startOfToday, + pageable = PageRequest.of(0, 10) ).content - } else { - visitRepository.findAllByVisitStatus( - checkedOutStatus, - PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "checkOutTime")) + + val recentlyCheckedOut = + visitRepository.findRecentlyCheckedOut( + siteId = siteId, + checkedOutStatus = checkedOutStatus, + pageable = PageRequest.of(0, 10) ).content - } return DashboardFeed( summary = DashboardSummary( currentlyOnPremises = currentlyOnPremises, - checkedInToday = checkedInToday, - checkedOutToday = checkedOutToday, - overdueCount = overdueCount + checkedInToday = checkedInToday, + checkedOutToday = checkedOutToday, + overdueCount = overdueCount, + overnightCount = overnightCount ), - activeVisitors = activeVisitors.map { it.toResponse() }, - overdueVisitors = overdueVisitors.map { it.toResponse() }, + activeVisitors = activeVisitors.map { it.toResponse() }, + overdueVisitors = overdueVisitors.map { it.toResponse() }, + overnightVisitors = overnightVisitors.map { it.toResponse() }, recentlyCheckedOut = recentlyCheckedOut.map { it.toResponse() } ) } diff --git a/src/main/kotlin/io/github/devcavin/gatelog/dashboard/dto/DashboardDtos.kt b/src/main/kotlin/io/github/devcavin/gatelog/dashboard/dto/DashboardDtos.kt index 3ee89d8..b9fb688 100644 --- a/src/main/kotlin/io/github/devcavin/gatelog/dashboard/dto/DashboardDtos.kt +++ b/src/main/kotlin/io/github/devcavin/gatelog/dashboard/dto/DashboardDtos.kt @@ -1,5 +1,6 @@ package io.github.devcavin.gatelog.dashboard.dto +import io.github.devcavin.gatelog.common.time.TimeUtil import io.github.devcavin.gatelog.visitors.dto.VisitResponse import java.time.OffsetDateTime @@ -8,12 +9,14 @@ data class DashboardSummary( val checkedInToday: Long, val checkedOutToday: Long, val overdueCount: Long, - val asOf: OffsetDateTime = OffsetDateTime.now() + val overnightCount: Long, + val asOf: OffsetDateTime = TimeUtil.timeNow() ) data class DashboardFeed( val summary: DashboardSummary, val activeVisitors: List, val overdueVisitors: List, + val overnightVisitors: List, val recentlyCheckedOut: List ) \ No newline at end of file diff --git a/src/main/kotlin/io/github/devcavin/gatelog/reports/ReportController.kt b/src/main/kotlin/io/github/devcavin/gatelog/reports/ReportController.kt index 1f1c4bc..df52283 100644 --- a/src/main/kotlin/io/github/devcavin/gatelog/reports/ReportController.kt +++ b/src/main/kotlin/io/github/devcavin/gatelog/reports/ReportController.kt @@ -21,7 +21,7 @@ import java.util.UUID @RequestMapping("/api/reports") class ReportController(private val reportService: ReportService) { @GetMapping("/visitors/csv") - @PreAuthorize("hasAnyRole('SUPER_ADMIN', 'MANAGER')") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')") fun exportVisitorsCsv( @AuthenticationPrincipal requestedBy: User, @RequestParam(required = false) from: OffsetDateTime?, diff --git a/src/main/kotlin/io/github/devcavin/gatelog/reports/ReportService.kt b/src/main/kotlin/io/github/devcavin/gatelog/reports/ReportService.kt index aa2fbf1..6bcb6f1 100644 --- a/src/main/kotlin/io/github/devcavin/gatelog/reports/ReportService.kt +++ b/src/main/kotlin/io/github/devcavin/gatelog/reports/ReportService.kt @@ -1,17 +1,17 @@ package io.github.devcavin.gatelog.reports import io.github.devcavin.gatelog.auth.AuthorizationService +import io.github.devcavin.gatelog.common.time.TimeUtil import io.github.devcavin.gatelog.users.User import io.github.devcavin.gatelog.visitors.Visit import io.github.devcavin.gatelog.visitors.VisitRepository import io.github.devcavin.gatelog.visitors.VisitSpecification import io.github.devcavin.gatelog.visitors.dto.VisitSearchParams -import org.apache.tomcat.util.http.fileupload.ByteArrayOutputStream import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import java.io.ByteArrayOutputStream import java.io.PrintWriter import java.time.Duration -import java.time.ZoneOffset import java.time.format.DateTimeFormatter @Service @@ -20,9 +20,9 @@ class ReportService( private val authorizationService: AuthorizationService ) { - private val formatter = DateTimeFormatter - .ofPattern("yyyy-MM-dd HH:mm:ss") - .withZone(ZoneOffset.UTC) + private val formatter = DateTimeFormatter.ofPattern( + "yyyy-MM-dd HH:mm:ss" + ) @Transactional(readOnly = true) fun exportVisitorsCsv( @@ -30,51 +30,79 @@ class ReportService( params: VisitSearchParams ): ByteArray { val scope = authorizationService.scopeFor(requestedBy) - val spec = VisitSpecification.search(scope, params) - val visitors = visitRepository.findAll(spec) - return buildCsv(visitors) - } - - private fun buildCsv(visitors: List): ByteArray { - val out = ByteArrayOutputStream() - val writer = PrintWriter(out) - writer.println( - csvRow( - "ID", "Name", "Phone", "Visitor Type", - "Purpose", "Status", "Zone", "Host", - "Registered By", "Site", - "Check In", "Check Out", "Duration (minutes)" - ) + val specification = VisitSpecification.search( + scope = scope, + params = params ) - visitors.forEach { v -> - val duration = v.checkOutTime?.let { - Duration.between(v.checkInTime, it).toMinutes().toString() - } ?: "" + val visits = visitRepository.findAll(specification) + + return buildCsv(visits) + } + private fun buildCsv(visits: List): ByteArray { + val output = ByteArrayOutputStream() + + PrintWriter(output).use { writer -> writer.println( csvRow( - v.id.toString(), - v.visitorType, - v.purpose, - v.visitStatus.name, - v.zone?.name ?: "", - v.createdBy.name, - v.site.name, - formatter.format(v.checkInTime), - v.checkOutTime?.let { formatter.format(it) } ?: "", - duration + "ID", + "Name", + "Phone", + "Visitor Type", + "Purpose", + "Status", + "Zone", + "Host", + "Registered By", + "Site", + "Check In", + "Check Out", + "Duration (minutes)", + "Overnight" ) ) + + visits.forEach { visit -> + writer.println( + csvRow( + visit.id.toString(), + visit.visitorProfile.name, + visit.visitorProfile.phoneNumber, + visit.visitorType, + visit.purpose, + visit.visitStatus.name, + visit.zone?.name ?: "", + "", + visit.createdBy.name, + visit.site.name, + formatter.format(visit.checkInTime), + visit.checkOutTime + ?.let(formatter::format) + ?: "", + durationMinutes(visit), + TimeUtil.isOvernight(visit.checkInTime).toString() + ) + ) + } } - writer.flush() - return out.toByteArray() + return output.toByteArray() } + private fun durationMinutes(visit: Visit): String = + visit.checkOutTime + ?.let { checkOutTime -> + Duration + .between(visit.checkInTime, checkOutTime) + .toMinutes() + .toString() + } + ?: "" + private fun csvRow(vararg fields: String): String = fields.joinToString(",") { field -> - '"' + field.replace("\"", "\"\"") + '"' + "\"${field.replace("\"", "\"\"")}\"" } -} \ No newline at end of file +} diff --git a/src/main/kotlin/io/github/devcavin/gatelog/sites/SiteController.kt b/src/main/kotlin/io/github/devcavin/gatelog/sites/SiteController.kt index 90e781d..044d4ce 100644 --- a/src/main/kotlin/io/github/devcavin/gatelog/sites/SiteController.kt +++ b/src/main/kotlin/io/github/devcavin/gatelog/sites/SiteController.kt @@ -18,19 +18,19 @@ class SiteController( ) { @PostMapping - @PreAuthorize("hasRole('SUPER_ADMIN')") + @PreAuthorize("hasRole('ADMIN')") fun create( @Valid @RequestBody request: SiteRequest ): ResponseEntity = ResponseEntity.status(HttpStatus.CREATED).body(siteService.create(request)) @GetMapping - @PreAuthorize("hasRole('SUPER_ADMIN')") + @PreAuthorize("hasRole('ADMIN')") fun getAll(): ResponseEntity> = ResponseEntity.ok(siteService.getAll()) @GetMapping("/{id}") - @PreAuthorize("hasAnyRole('SUPER_ADMIN', 'MANAGER')") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')") fun getById( @AuthenticationPrincipal requestedBy: User, @PathVariable id: UUID @@ -38,7 +38,7 @@ class SiteController( ResponseEntity.ok(siteService.getById(requestedBy, id)) @PutMapping("/{id}") - @PreAuthorize("hasRole('SUPER_ADMIN')") + @PreAuthorize("hasRole('ADMIN')") fun update( @PathVariable id: UUID, @Valid @RequestBody request: SiteRequest @@ -46,7 +46,7 @@ class SiteController( ResponseEntity.ok(siteService.update(id, request)) @DeleteMapping("/{id}") - @PreAuthorize("hasRole('SUPER_ADMIN')") + @PreAuthorize("hasRole('ADMIN')") fun delete( @PathVariable id: UUID ): ResponseEntity { diff --git a/src/main/kotlin/io/github/devcavin/gatelog/sites/SiteService.kt b/src/main/kotlin/io/github/devcavin/gatelog/sites/SiteService.kt index 253a241..bca14db 100644 --- a/src/main/kotlin/io/github/devcavin/gatelog/sites/SiteService.kt +++ b/src/main/kotlin/io/github/devcavin/gatelog/sites/SiteService.kt @@ -17,46 +17,93 @@ class SiteService( private val siteRepository: SiteRepository, private val authorizationService: AuthorizationService ) { + @Transactional fun create(request: SiteRequest): SiteResponse { - if (siteRepository.existsByNameAndLocation(request.name, request.location)) { - throw ConflictException("Site with this name and location already exists") + if ( + siteRepository.existsByNameAndLocation( + request.name, + request.location + ) + ) { + throw ConflictException( + "Site with this name and location already exists" + ) } - val site = siteRepository.save(request.toEntity()) - - return site.toResponse() + return siteRepository + .save(request.toEntity()) + .toResponse() } @Transactional(readOnly = true) - fun getAll(): List = siteRepository.findAll().map { it.toResponse() } + fun getAll(): List = + siteRepository + .findAll() + .map { it.toResponse() } @Transactional(readOnly = true) - fun getById(requestBy: User, id: UUID): SiteResponse { - - // site access scope check - authorizationService.assertCovers(requestBy, id) + fun getById( + requestedBy: User, + siteId: UUID + ): SiteResponse { + authorizationService.assertCovers( + requestedBy, + siteId + ) - val site = siteRepository.findById(id) - .orElseThrow { ResourceNotFoundException("Site", id) } - return site.toResponse() + return findById(siteId) + .toResponse() } @Transactional - fun update(id: UUID, request: SiteRequest): SiteResponse { - val site = siteRepository.findById(id) - .orElseThrow { ResourceNotFoundException("Site", id) } + fun update( + id: UUID, + request: SiteRequest + ): SiteResponse { + val site = findById(id) + + if ( + site.name != request.name || + site.location != request.location + ) { + if ( + siteRepository.existsByNameAndLocation( + request.name, + request.location + ) + ) { + throw ConflictException( + "Site with this name and location already exists" + ) + } + } site.name = request.name site.location = request.location - return siteRepository.save(site).toResponse() + return siteRepository + .save(site) + .toResponse() } @Transactional fun delete(id: UUID) { - if (!siteRepository.existsById(id)) throw ResourceNotFoundException("Site", id) + val site = findById(id) - return siteRepository.deleteById(id) + siteRepository.delete(site) } + + /** + * Internal site lookup. + * + * Keeps repository lookup and not-found handling in one place. + * Authorization is intentionally handled by the public operation + * that requires it. + */ + private fun findById(id: UUID): Site = + siteRepository.findById(id) + .orElseThrow { + ResourceNotFoundException("Site", id) + } } \ No newline at end of file diff --git a/src/main/kotlin/io/github/devcavin/gatelog/users/UserController.kt b/src/main/kotlin/io/github/devcavin/gatelog/users/UserController.kt index 45c19c2..ed461f9 100644 --- a/src/main/kotlin/io/github/devcavin/gatelog/users/UserController.kt +++ b/src/main/kotlin/io/github/devcavin/gatelog/users/UserController.kt @@ -23,7 +23,7 @@ import java.util.UUID @RequestMapping("/api/users") class UserController(private val userService: UserService) { @PostMapping("/register") - @PreAuthorize("hasAnyRole('SUPER_ADMIN', 'MANAGER')") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')") fun createUser( @AuthenticationPrincipal requestedBy: User, @Valid @RequestBody request: CreateUserRequest @@ -36,14 +36,14 @@ class UserController(private val userService: UserService) { } @GetMapping - @PreAuthorize("hasAnyRole('SUPER_ADMIN', 'MANAGER')") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')") fun getAll( @AuthenticationPrincipal requestedBy: User ): ResponseEntity> = ResponseEntity.ok(userService.getAll(requestedBy)) @GetMapping("/{id}") - @PreAuthorize("hasAnyRole('SUPER_ADMIN', 'MANAGER')") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')") fun getById( @AuthenticationPrincipal requestedBy: User, @PathVariable id: UUID @@ -51,7 +51,7 @@ class UserController(private val userService: UserService) { ResponseEntity.ok(userService.getById(requestedBy, id)) @PutMapping("/{id}") - @PreAuthorize("hasAnyRole('SUPER_ADMIN', 'MANAGER')") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')") fun updateUser( @AuthenticationPrincipal requestedBy: User, @PathVariable id: UUID, @@ -60,7 +60,7 @@ class UserController(private val userService: UserService) { ResponseEntity.ok(userService.updateUser(requestedBy, id, request)) @PatchMapping("/{id}/deactivate") - @PreAuthorize("hasAnyRole('SUPER_ADMIN', 'MANAGER')") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')") fun deactivate( @AuthenticationPrincipal requestedBy: User, @PathVariable id: UUID @@ -68,7 +68,7 @@ class UserController(private val userService: UserService) { ResponseEntity.ok(userService.deactivate(requestedBy, id)) @PatchMapping("/{id}/activate") - @PreAuthorize("hasRole('SUPER_ADMIN')") + @PreAuthorize("hasRole('ADMIN')") fun activate( @AuthenticationPrincipal requestedBy: User, @PathVariable id: UUID diff --git a/src/main/kotlin/io/github/devcavin/gatelog/users/UserService.kt b/src/main/kotlin/io/github/devcavin/gatelog/users/UserService.kt index 5c7bf5f..4602db0 100644 --- a/src/main/kotlin/io/github/devcavin/gatelog/users/UserService.kt +++ b/src/main/kotlin/io/github/devcavin/gatelog/users/UserService.kt @@ -11,7 +11,7 @@ import io.github.devcavin.gatelog.users.dto.* import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional -import java.util.* +import java.util.UUID @Service class UserService( @@ -21,15 +21,38 @@ class UserService( private val authorizationService: AuthorizationService, private val passwordEncoder: PasswordEncoder ) { + @Transactional - fun createUser(request: CreateUserRequest, requestedBy: User): UserResponse { - if (userRepository.existsByEmail(request.email)) throw ConflictException("User already exists") + fun createUser( + request: CreateUserRequest, + requestedBy: User + ): UserResponse { - val targetRole = roleRepository.findByName(request.roleName) ?: throw ResourceNotFoundException("Role", request.roleName) + if (userRepository.existsByEmail(request.email)) { + throw ConflictException("User already exists") + } - authorizationService.assertCanCreateUser(requestedBy, targetRole.name, request.siteId) + val targetRole = roleRepository + .findByName(request.roleName) + ?: throw ResourceNotFoundException( + "Role", + request.roleName + ) + + authorizationService.assertCanCreateUser( + requestedBy = requestedBy, + targetRoleName = targetRole.name, + targetSiteId = request.siteId + ) - val site = siteRepository.findById(request.siteId).orElseThrow { ResourceNotFoundException("Site", request.siteId) } + val site = siteRepository + .findById(request.siteId) + .orElseThrow { + ResourceNotFoundException( + "Site", + request.siteId + ) + } val user = User( name = request.name, @@ -39,30 +62,41 @@ class UserService( site = site ) - val savedUser = userRepository.save(user) - return savedUser.toResponse() + return userRepository + .save(user) + .toResponse() } @Transactional(readOnly = true) - fun getAll(requestedBy: User): List { - return when (val scope = authorizationService.scopeFor(requestedBy)) { - is AccessScope.Global -> userRepository - .findAllWithRole() - .map { it.toResponse() } - - is AccessScope.Site -> userRepository - .findAllBySiteIdWithRole(scope.siteId) - .filter { it.role.name == "STAFF" } - .map { it.toResponse() } + fun getAll( + requestedBy: User + ): List = + when (val scope = authorizationService.scopeFor(requestedBy)) { + + is AccessScope.Global -> + userRepository + .findAllWithRole() + .map { it.toResponse() } + + is AccessScope.Site -> + userRepository + .findAllBySiteIdWithRole(scope.siteId) + .filter { it.role.name == "STAFF" } + .map { it.toResponse() } } - } @Transactional(readOnly = true) - fun getById(requestedBy: User, userId: UUID): UserResponse { - val target = userRepository.findById(userId) - .orElseThrow { ResourceNotFoundException("User", userId) } + fun getById( + requestedBy: User, + userId: UUID + ): UserResponse { - authorizationService.assertCanViewUser(requestedBy, target) + val target = findById(userId) + + authorizationService.assertCanViewUser( + requestedBy, + target + ) return target.toResponse() } @@ -73,51 +107,94 @@ class UserService( userId: UUID, request: UpdateUserRequest ): UserResponse { - val target = userRepository.findById(userId) - .orElseThrow { ResourceNotFoundException("User", userId) } - authorizationService.assertCanViewUser(requestedBy, target) - authorizationService.assertCanUpdateUser(requestedBy, target, request.roleName) + val target = findById(userId) + + authorizationService.assertCanViewUser( + requestedBy, + target + ) - if (request.email != target.email && userRepository.existsByEmail(request.email)) { - throw ConflictException("Email already in use: ${request.email}") + authorizationService.assertCanUpdateUser( + requestedBy = requestedBy, + target = target, + newRoleName = request.roleName + ) + + if ( + request.email != target.email && + userRepository.existsByEmail(request.email) + ) { + throw ConflictException( + "Email already in use: ${request.email}" + ) } - val newRole = roleRepository.findByName(request.roleName) - ?: throw ResourceNotFoundException("Role", request.roleName) + val newRole = roleRepository + .findByName(request.roleName) + ?: throw ResourceNotFoundException( + "Role", + request.roleName + ) target.name = request.name target.email = request.email target.role = newRole - return userRepository.save(target).toResponse() + return userRepository + .save(target) + .toResponse() } @Transactional - fun deactivate(requestedBy: User, userId: UUID): UserResponse { + fun deactivate( + requestedBy: User, + userId: UUID + ): UserResponse { + if (requestedBy.id == userId) { - throw InvalidStateException("You cannot deactivate your own account") + throw InvalidStateException( + "You cannot deactivate your own account" + ) } - val target = userRepository.findById(userId) - .orElseThrow { ResourceNotFoundException("User", userId) } - authorizationService.assertCanViewUser(requestedBy, target) + val target = findById(userId) - authorizationService.assertCanDeactivateUser(requestedBy, target) + authorizationService.assertCanViewUser( + requestedBy, + target + ) + + authorizationService.assertCanDeactivateUser( + requestedBy, + target + ) target.isActive = false - return userRepository.save(target).toResponse() + + return userRepository + .save(target) + .toResponse() } @Transactional - fun activate(requestedBy: User, userId: UUID): UserResponse { - val target = userRepository.findById(userId) - .orElseThrow { ResourceNotFoundException("User", userId) } + fun activate( + requestedBy: User, + userId: UUID + ): UserResponse { + + val target = findById(userId) - authorizationService.assertCanViewUser(requestedBy, target) + authorizationService.assertCanViewUser( + requestedBy, + target + ) target.isActive = true - return userRepository.save(target).toResponse() + + return userRepository + .save(target) + .toResponse() } @Transactional @@ -125,11 +202,37 @@ class UserService( requestedBy: User, request: ChangePasswordRequest ): UserResponse { - if (!passwordEncoder.matches(request.currentPassword, requestedBy.passwordHash)) { + + if ( + !passwordEncoder.matches( + request.currentPassword, + requestedBy.passwordHash + ) + ) { throw InvalidCredentialsException() } - requestedBy.passwordHash = passwordEncoder.encode(request.newPassword) - return userRepository.save(requestedBy).toResponse() + requestedBy.passwordHash = + passwordEncoder.encode(request.newPassword) + + return userRepository + .save(requestedBy) + .toResponse() } + + /** + * Reusable user lookup. + * + * Persistence lookup and not-found handling live here. + * Authorization remains with the operation using the user. + */ + private fun findById(userId: UUID): User = + userRepository + .findById(userId) + .orElseThrow { + ResourceNotFoundException( + "User", + userId + ) + } } \ No newline at end of file diff --git a/src/main/kotlin/io/github/devcavin/gatelog/visitors/OverdueVisitJob.kt b/src/main/kotlin/io/github/devcavin/gatelog/visitors/OverdueVisitJob.kt index 7a89b83..4990885 100644 --- a/src/main/kotlin/io/github/devcavin/gatelog/visitors/OverdueVisitJob.kt +++ b/src/main/kotlin/io/github/devcavin/gatelog/visitors/OverdueVisitJob.kt @@ -1,14 +1,13 @@ package io.github.devcavin.gatelog.visitors import io.github.devcavin.gatelog.common.exception.ResourceNotFoundException +import io.github.devcavin.gatelog.common.time.TimeUtil import io.github.devcavin.gatelog.sites.SiteRepository import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Value import org.springframework.scheduling.annotation.Scheduled import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional -import java.time.OffsetDateTime -import java.time.ZoneOffset @Service class OverdueVisitJob( @@ -16,39 +15,57 @@ class OverdueVisitJob( private val visitorStatusRepository: VisitStatusRepository, private val siteRepository: SiteRepository, - @Value("\${gatelog.scheduler.overdue-threshold-hours:2}") + @Value($$"${gatelog.scheduler.overdue-threshold-hours:2}") private val overdueThresholdHours: Long ) { + private val log = LoggerFactory.getLogger(OverdueVisitJob::class.java) - // runs scheduler every 15 mins - @Scheduled(fixedRateString = "\${gatelog.scheduler.overdue-job-rate-ms:900000}") + @Scheduled( + fixedRateString = $$"${gatelog.scheduler.overdue-job-rate-ms:900000}" + ) @Transactional fun flagOverdueVisitors() { - val overdueStatus = visitorStatusRepository.findByName("OVERDUE")?: throw ResourceNotFoundException("Visit Status", "OVERDUE") - val threshold = OffsetDateTime.now(ZoneOffset.UTC).minusHours(overdueThresholdHours) + val overdueStatus = + visitorStatusRepository.findByName("OVERDUE") + ?: throw ResourceNotFoundException( + "Visit Status", + "OVERDUE" + ) + + val threshold = + TimeUtil.timeNow().minusHours(overdueThresholdHours) val sites = siteRepository.findAll() var totalFlagged = 0 sites.forEach { site -> + + val siteId = requireNotNull(site.id) + val flagged = visitRepository.markOverdue( - siteId = site.id!!, threshold = threshold, overdueStatus = overdueStatus ) if (flagged > 0) { - log.info("Flagged $flagged overdue visitor(s) at site $site") + log.info( + "Flagged {} overdue visitor(s) at site {}", + flagged, + siteId + ) } totalFlagged += flagged } if (totalFlagged > 0) { - log.info("Overdue job complete - $totalFlagged visitor(s) flagged") + log.info( + "Overdue job complete - {} visitor(s) flagged", + totalFlagged + ) } } -} \ No newline at end of file +} diff --git a/src/main/kotlin/io/github/devcavin/gatelog/visitors/Visit.kt b/src/main/kotlin/io/github/devcavin/gatelog/visitors/Visit.kt index b9daa9d..f3f00b0 100644 --- a/src/main/kotlin/io/github/devcavin/gatelog/visitors/Visit.kt +++ b/src/main/kotlin/io/github/devcavin/gatelog/visitors/Visit.kt @@ -1,6 +1,7 @@ package io.github.devcavin.gatelog.visitors import io.github.devcavin.gatelog.common.persistence.BaseEntity +import io.github.devcavin.gatelog.common.time.TimeUtil import io.github.devcavin.gatelog.sites.Site import io.github.devcavin.gatelog.users.User import io.github.devcavin.gatelog.zones.Zone @@ -51,7 +52,7 @@ class Visit( var purpose: String = "General Visit", @Column(name = "check_in_time", nullable = false, updatable = false) - var checkInTime: OffsetDateTime = OffsetDateTime.now(), + var checkInTime: OffsetDateTime = TimeUtil.timeNow(), @Column(name = "check_out_time") var checkOutTime: OffsetDateTime? = null diff --git a/src/main/kotlin/io/github/devcavin/gatelog/visitors/VisitRepository.kt b/src/main/kotlin/io/github/devcavin/gatelog/visitors/VisitRepository.kt index 75d87ae..170b460 100644 --- a/src/main/kotlin/io/github/devcavin/gatelog/visitors/VisitRepository.kt +++ b/src/main/kotlin/io/github/devcavin/gatelog/visitors/VisitRepository.kt @@ -6,15 +6,20 @@ 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.data.repository.query.Param import org.springframework.stereotype.Repository import java.time.OffsetDateTime -import java.util.* +import java.util.UUID @Repository interface VisitRepository : JpaRepository, JpaSpecificationExecutor { + /* + * Visitor history + */ + fun findTopByVisitorProfileIdOrderByCheckInTimeDesc( visitorProfileId: UUID ): Visit? @@ -29,95 +34,172 @@ interface VisitRepository : statusName: String ): Visit? + /* + * Overdue processing + * + * The overdue threshold belongs to the scheduled job. + * The dashboard should only query visits that have already + * been transitioned to the OVERDUE status. + */ + @Modifying - @Query(""" - UPDATE Visit v - SET v.visitStatus = :overdueStatus - WHERE v.site.id = :siteId - AND v.visitStatus.name = 'CHECKED_IN' - AND v.checkInTime <= :threshold - """) + @Query( + """ + UPDATE Visit v + SET v.visitStatus = :overdueStatus + WHERE v.visitStatus.name = 'CHECKED_IN' + AND v.checkInTime <= :threshold + """ + ) fun markOverdue( - siteId: UUID, - threshold: OffsetDateTime, - overdueStatus: VisitStatus + @Param("threshold") threshold: OffsetDateTime, + @Param("overdueStatus") overdueStatus: VisitStatus ): Int - fun countBySiteIdAndVisitStatus( - siteId: UUID, - visitStatus: VisitStatus + /* + * Dashboard queries + * + * siteId = null -> global scope (ADMIN) + * siteId != null -> site scope (MANAGER / STAFF) + * + * Keeping the scope handling here means DashboardService + * does not need separate global/site repository calls. + */ + + @Query( + """ + SELECT COUNT(v) + FROM Visit v + WHERE (:siteId IS NULL OR v.site.id = :siteId) + AND ( + v.visitStatus = :checkedInStatus + OR v.visitStatus = :overdueStatus + ) + """ + ) + fun countCurrentlyOnPremises( + @Param("siteId") siteId: UUID?, + @Param("checkedInStatus") checkedInStatus: VisitStatus, + @Param("overdueStatus") overdueStatus: VisitStatus ): Long - fun countByVisitStatus(visitStatus: VisitStatus): Long - - @Query(""" - SELECT v FROM Visit 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 Visit v - WHERE v.checkInTime >= :startOfDay - AND v.checkInTime < :endOfDay - """) - fun countCheckedInTodayGlobal( - startOfDay: OffsetDateTime, - endOfDay: OffsetDateTime + @Query( + """ + SELECT COUNT(v) + FROM Visit v + WHERE (:siteId IS NULL OR v.site.id = :siteId) + AND v.checkInTime >= :startOfDay + AND v.checkInTime < :endOfDay + """ + ) + fun countCheckedInToday( + @Param("siteId") siteId: UUID?, + @Param("startOfDay") startOfDay: OffsetDateTime, + @Param("endOfDay") endOfDay: OffsetDateTime ): Long - fun countBySiteIdAndVisitStatusAndCheckOutTimeBetween( - siteId: UUID, - visitStatus: VisitStatus, - start: OffsetDateTime, - end: OffsetDateTime + @Query( + """ + SELECT COUNT(v) + FROM Visit v + WHERE (:siteId IS NULL OR v.site.id = :siteId) + AND v.visitStatus = :checkedOutStatus + AND v.checkOutTime >= :startOfDay + AND v.checkOutTime < :endOfDay + """ + ) + fun countCheckedOutToday( + @Param("siteId") siteId: UUID?, + @Param("checkedOutStatus") checkedOutStatus: VisitStatus, + @Param("startOfDay") startOfDay: OffsetDateTime, + @Param("endOfDay") endOfDay: OffsetDateTime ): Long - fun countByVisitStatusAndCheckOutTimeBetween( - visitStatus: VisitStatus, - start: OffsetDateTime, - end: OffsetDateTime + @Query( + """ + SELECT COUNT(v) + FROM Visit v + WHERE (:siteId IS NULL OR v.site.id = :siteId) + AND v.visitStatus = :overdueStatus + """ + ) + fun countOverdue( + @Param("siteId") siteId: UUID?, + @Param("overdueStatus") overdueStatus: VisitStatus ): Long - fun findAllBySiteIdAndVisitStatus( - siteId: UUID, - visitStatus: VisitStatus, + @Query( + """ + SELECT v + FROM Visit v + WHERE (:siteId IS NULL OR v.site.id = :siteId) + AND v.visitStatus = :checkedInStatus + ORDER BY v.checkInTime DESC + """ + ) + fun findActiveVisitors( + @Param("siteId") siteId: UUID?, + @Param("checkedInStatus") checkedInStatus: VisitStatus, pageable: Pageable ): Page - @Query(""" - SELECT v FROM Visit 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 Visit v - WHERE v.visitStatus = :visitStatus - """) - fun findAllByVisitStatus( - visitStatus: VisitStatus, + @Query( + """ + SELECT v + FROM Visit v + WHERE (:siteId IS NULL OR v.site.id = :siteId) + AND v.visitStatus = :overdueStatus + ORDER BY v.checkInTime ASC + """ + ) + fun findOverdueVisitors( + @Param("siteId") siteId: UUID?, + @Param("overdueStatus") overdueStatus: VisitStatus, pageable: Pageable ): Page - @Query(""" - SELECT v FROM Visit v - WHERE v.visitStatus.name = 'CHECKED_IN' - AND v.checkInTime <= :threshold - """) - fun findAllOverdueGlobal( - threshold: OffsetDateTime - ): List + @Query( + """ + SELECT v + FROM Visit v + WHERE (:siteId IS NULL OR v.site.id = :siteId) + AND v.visitStatus = :checkedOutStatus + ORDER BY v.checkOutTime DESC + """ + ) + fun findRecentlyCheckedOut( + @Param("siteId") siteId: UUID?, + @Param("checkedOutStatus") checkedOutStatus: VisitStatus, + pageable: Pageable + ): Page + + @Query( + """ + SELECT v + FROM Visit v + WHERE (:siteId IS NULL OR v.site.id = :siteId) + AND v.checkOutTime IS NULL + AND v.checkInTime < :startOfToday + ORDER BY v.checkInTime ASC + """ + ) + fun findOvernightVisitors( + @Param("siteId") siteId: UUID?, + @Param("startOfToday") startOfToday: OffsetDateTime, + pageable: Pageable + ): Page + + @Query( + """ + SELECT COUNT(v) + FROM Visit v + WHERE (:siteId IS NULL OR v.site.id = :siteId) + AND v.checkOutTime IS NULL + AND v.checkInTime < :startOfToday + """ + ) + fun countOvernight( + @Param("siteId") siteId: UUID?, + @Param("startOfToday") startOfToday: OffsetDateTime + ): Long } \ No newline at end of file diff --git a/src/main/kotlin/io/github/devcavin/gatelog/visitors/VisitService.kt b/src/main/kotlin/io/github/devcavin/gatelog/visitors/VisitService.kt index be9397b..769008f 100644 --- a/src/main/kotlin/io/github/devcavin/gatelog/visitors/VisitService.kt +++ b/src/main/kotlin/io/github/devcavin/gatelog/visitors/VisitService.kt @@ -4,21 +4,14 @@ import io.github.devcavin.gatelog.auth.AuthorizationService import io.github.devcavin.gatelog.common.exception.ConflictException import io.github.devcavin.gatelog.common.exception.InvalidStateException import io.github.devcavin.gatelog.common.exception.ResourceNotFoundException +import io.github.devcavin.gatelog.common.time.TimeProvider import io.github.devcavin.gatelog.users.User -import io.github.devcavin.gatelog.visitors.dto.RegisterVisitRequest -import io.github.devcavin.gatelog.visitors.dto.ReturningVisitorResponse -import io.github.devcavin.gatelog.visitors.dto.VisitResponse -import io.github.devcavin.gatelog.visitors.dto.VisitSearchParams -import io.github.devcavin.gatelog.visitors.dto.VisitorProfileSummary -import io.github.devcavin.gatelog.visitors.dto.toResponse -import io.github.devcavin.gatelog.visitors.dto.toVisitSummary +import io.github.devcavin.gatelog.visitors.dto.* import io.github.devcavin.gatelog.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.UUID private const val CHECKED_IN = "CHECKED_IN" @@ -30,7 +23,8 @@ class VisitService( private val visitStatusRepository: VisitStatusRepository, private val zoneRepository: ZoneRepository, private val visitorProfileRepository: VisitorProfileRepository, - private val authorizationService: AuthorizationService + private val authorizationService: AuthorizationService, + private val timeProvider: TimeProvider ) { @Transactional @@ -38,48 +32,40 @@ class VisitService( requestedBy: User, request: RegisterVisitRequest ): VisitResponse { - val site = requestedBy.site val siteId = requireNotNull(site.id) { "Authenticated user has no site" } - authorizationService.assertCovers( - requestedBy, + val zone = zoneRepository.findByIdAndSiteId( + request.zoneId, siteId + ) ?: throw ResourceNotFoundException( + "Zone", + request.zoneId ) - 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 - ) + visitorProfileRepository.findBySiteIdAndPhoneNumber( + siteId, + request.phone + ) ?: visitorProfileRepository.save( + VisitorProfile( + name = request.name, + phoneNumber = request.phone, + site = site ) + ) + + val profileId = requireNotNull(profile.id) { + "Visitor profile has no ID" + } val checkedInVisit = - visitRepository - .findFirstByVisitorProfileIdAndVisitStatusName( - requireNotNull(profile.id), - CHECKED_IN - ) + visitRepository.findFirstByVisitorProfileIdAndVisitStatusName( + profileId, + CHECKED_IN + ) if (checkedInVisit != null) { throw ConflictException( @@ -101,7 +87,8 @@ class VisitService( createdBy = requestedBy, visitStatus = checkedInStatus, visitorType = request.visitorType, - purpose = request.purpose + purpose = request.purpose, + checkInTime = timeProvider.timeNow() ) return visitRepository @@ -113,20 +100,11 @@ class VisitService( fun getById( requestedBy: User, visitId: UUID - ): VisitResponse { - - val visitor = visitRepository.findById(visitId) - .orElseThrow { - ResourceNotFoundException("Visitor", visitId) - } - - authorizationService.assertCanAccessVisitor( + ): VisitResponse = + findAccessibleVisit( requestedBy, - visitor - ) - - return visitor.toResponse() - } + visitId + ).toResponse() @Transactional(readOnly = true) fun search( @@ -134,12 +112,14 @@ class VisitService( params: VisitSearchParams, pageable: Pageable ): Page { - val scope = authorizationService.scopeFor(requestedBy) return visitRepository .findAll( - VisitSpecification.search(scope, params), + VisitSpecification.search( + scope, + params + ), pageable ) .map { it.toResponse() } @@ -150,21 +130,15 @@ class VisitService( requestedBy: User, visitId: UUID ): VisitResponse { - - val visitor = visitRepository.findById(visitId) - .orElseThrow { - ResourceNotFoundException("Visitor", visitId) - } - - authorizationService.assertCanAccessVisitor( + val visit = findAccessibleVisit( requestedBy, - visitor + visitId ) - if (visitor.visitStatus.name != CHECKED_IN) { + if (visit.visitStatus.name != CHECKED_IN) { throw InvalidStateException( "Visitor is already ${ - visitor.visitStatus.name + visit.visitStatus.name .lowercase() .replace('_', ' ') }" @@ -178,11 +152,11 @@ class VisitService( CHECKED_OUT ) - visitor.visitStatus = checkedOutStatus - visitor.checkOutTime = OffsetDateTime.now() + visit.visitStatus = checkedOutStatus + visit.checkOutTime = timeProvider.timeNow() return visitRepository - .save(visitor) + .save(visit) .toResponse() } @@ -191,37 +165,60 @@ class VisitService( 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 + visitorProfileRepository.findBySiteIdAndPhoneNumber( + siteId, + phone + ) ?: return null + + val profileId = requireNotNull(profile.id) { + "Visitor profile has no ID" + } val lastVisit = visitRepository .findTopByVisitorProfileIdOrderByCheckInTimeDesc( - requireNotNull(profile.id) + profileId ) return ReturningVisitorResponse( profile = VisitorProfileSummary( - id = requireNotNull(profile.id), + id = profileId, name = profile.name, phoneNumber = profile.phoneNumber ), lastVisit = lastVisit?.toVisitSummary() ) } -} \ No newline at end of file + + /** + * Loads a visit and verifies that the authenticated user + * is authorized to access it. + * + * Individual visit operations should use this helper + * rather than performing an unprotected repository lookup. + */ + private fun findAccessibleVisit( + requestedBy: User, + visitId: UUID + ): Visit { + val visit = visitRepository.findById(visitId) + .orElseThrow { + ResourceNotFoundException( + "Visit", + visitId + ) + } + + authorizationService.assertCanAccessVisit( + requestedBy, + visit + ) + + return visit + } +} diff --git a/src/main/kotlin/io/github/devcavin/gatelog/visitors/VisitSpecification.kt b/src/main/kotlin/io/github/devcavin/gatelog/visitors/VisitSpecification.kt index b535a00..b2f37f5 100644 --- a/src/main/kotlin/io/github/devcavin/gatelog/visitors/VisitSpecification.kt +++ b/src/main/kotlin/io/github/devcavin/gatelog/visitors/VisitSpecification.kt @@ -1,7 +1,9 @@ package io.github.devcavin.gatelog.visitors import io.github.devcavin.gatelog.auth.AccessScope +import io.github.devcavin.gatelog.sites.Site import io.github.devcavin.gatelog.visitors.dto.VisitSearchParams +import io.github.devcavin.gatelog.zones.Zone import jakarta.persistence.criteria.Predicate import org.springframework.data.jpa.domain.Specification import java.util.UUID @@ -16,88 +18,72 @@ object VisitSpecification { val predicates = mutableListOf() if (scope is AccessScope.Site) { - predicates.add( - cb.equal( - root.get("site").get("id"), - scope.siteId - ) + predicates += cb.equal( + root.get("site").get("id"), + scope.siteId ) } val profile = root.get("visitorProfile") params.name - ?.takeIf { it.isNotBlank() } - ?.let { - predicates.add( - cb.like( - cb.lower(profile.get("name")), - "%${it.lowercase()}%" - ) + ?.takeIf(String::isNotBlank) + ?.let { name -> + predicates += cb.like( + cb.lower(profile.get("name")), + "%${name.lowercase()}%" ) } params.phone - ?.takeIf { it.isNotBlank() } - ?.let { - predicates.add( - cb.like( - profile.get("phoneNumber"), - "%$it%" - ) + ?.takeIf(String::isNotBlank) + ?.let { phone -> + predicates += cb.like( + profile.get("phoneNumber"), + "%$phone%" ) } params.visitorType - ?.takeIf { it.isNotBlank() } - ?.let { - predicates.add( - cb.equal( - root.get("visitorType"), - it - ) + ?.takeIf(String::isNotBlank) + ?.let { visitorType -> + predicates += cb.equal( + root.get("visitorType"), + visitorType ) } - params.zoneId?.let { - predicates.add( - cb.equal( - root.get("zone").get("id"), - it - ) + params.zoneId?.let { zoneId -> + predicates += cb.equal( + root.get("zone").get("id"), + zoneId ) } params.status - ?.takeIf { it.isNotBlank() } - ?.let { - predicates.add( - cb.equal( - root.get("visitStatus") - .get("name"), - it - ) + ?.takeIf(String::isNotBlank) + ?.let { status -> + predicates += cb.equal( + root.get("visitStatus") + .get("name"), + status ) } - params.from?.let { - predicates.add( - cb.greaterThanOrEqualTo( - root.get("checkInTime"), - it - ) + params.from?.let { from -> + predicates += cb.greaterThanOrEqualTo( + root.get("checkInTime"), + from ) } - params.to?.let { - predicates.add( - cb.lessThanOrEqualTo( - root.get("checkInTime"), - it - ) + params.to?.let { to -> + predicates += cb.lessThanOrEqualTo( + root.get("checkInTime"), + to ) } cb.and(*predicates.toTypedArray()) } -} \ No newline at end of file +} diff --git a/src/main/kotlin/io/github/devcavin/gatelog/visitors/VisitorProfileController.kt b/src/main/kotlin/io/github/devcavin/gatelog/visitors/VisitorProfileController.kt index 93c7581..e71b270 100644 --- a/src/main/kotlin/io/github/devcavin/gatelog/visitors/VisitorProfileController.kt +++ b/src/main/kotlin/io/github/devcavin/gatelog/visitors/VisitorProfileController.kt @@ -7,6 +7,7 @@ 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,10 +21,21 @@ class VisitorProfileController( private val visitorProfileService: VisitorProfileService ) { + @GetMapping("/{profileId}") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER', 'STAFF')") + fun getById( + @AuthenticationPrincipal requestedBy: User, + @PathVariable profileId: UUID + ): ResponseEntity = + ResponseEntity.ok( + visitorProfileService.getById( + requestedBy, + profileId + ) + ) + @PutMapping("/{profileId}") - @PreAuthorize( - "hasAnyRole('SUPER_ADMIN', 'MANAGER', 'STAFF')" - ) + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')") fun update( @AuthenticationPrincipal requestedBy: User, @PathVariable profileId: UUID, @@ -36,4 +48,4 @@ class VisitorProfileController( request ) ) -} \ No newline at end of file +} diff --git a/src/main/kotlin/io/github/devcavin/gatelog/visitors/VisitorProfileService.kt b/src/main/kotlin/io/github/devcavin/gatelog/visitors/VisitorProfileService.kt index f3d7354..7a991de 100644 --- a/src/main/kotlin/io/github/devcavin/gatelog/visitors/VisitorProfileService.kt +++ b/src/main/kotlin/io/github/devcavin/gatelog/visitors/VisitorProfileService.kt @@ -9,7 +9,7 @@ import io.github.devcavin.gatelog.visitors.dto.VisitorProfileResponse import io.github.devcavin.gatelog.visitors.dto.toResponse import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional -import java.util.UUID +import java.util.* @Service class VisitorProfileService( @@ -18,34 +18,47 @@ class VisitorProfileService( private val authorizationService: AuthorizationService ) { + @Transactional(readOnly = true) + fun getById( + requestedBy: User, + profileId: UUID + ): VisitorProfileResponse { + val profile = findProfileById(profileId) + + val profileSiteId = requireNotNull(profile.site.id) + + authorizationService.assertCovers( + requestedBy, + profileSiteId + ) + + val visitCount = getVisitCount( + profileSiteId, + profileId + ) + + return profile.toResponse(visitCount) + } + @Transactional fun update( requestedBy: User, profileId: UUID, request: UpdateVisitorProfileRequest ): VisitorProfileResponse { + val profile = findProfileById(profileId) - val profile = visitorProfileRepository.findById(profileId) - .orElseThrow { - ResourceNotFoundException( - "VisitorProfile", - profileId - ) - } - - val siteId = requireNotNull(profile.site.id) { - "Visitor profile has no site" - } + val profileSiteId = requireNotNull(profile.site.id) authorizationService.assertCovers( requestedBy, - siteId + profileSiteId ) if ( request.phoneNumber != profile.phoneNumber && visitorProfileRepository.existsBySiteIdAndPhoneNumber( - siteId, + profileSiteId, request.phoneNumber ) ) { @@ -59,12 +72,29 @@ class VisitorProfileService( val saved = visitorProfileRepository.save(profile) - val visitCount = - visitRepository.countBySiteIdAndVisitorProfileId( - siteId, - profileId - ) + val visitCount = getVisitCount( + profileSiteId, + profileId + ) return saved.toResponse(visitCount) } -} \ No newline at end of file + + private fun findProfileById(profileId: UUID): VisitorProfile = + visitorProfileRepository.findById(profileId) + .orElseThrow { + ResourceNotFoundException( + "VisitorProfile", + profileId + ) + } + + private fun getVisitCount( + siteId: UUID, + profileId: UUID + ): Long = + visitRepository.countBySiteIdAndVisitorProfileId( + siteId, + profileId + ) +} diff --git a/src/main/kotlin/io/github/devcavin/gatelog/visitors/dto/VisitorResponses.kt b/src/main/kotlin/io/github/devcavin/gatelog/visitors/dto/VisitorResponses.kt index 8e574c0..ce2c2b1 100644 --- a/src/main/kotlin/io/github/devcavin/gatelog/visitors/dto/VisitorResponses.kt +++ b/src/main/kotlin/io/github/devcavin/gatelog/visitors/dto/VisitorResponses.kt @@ -1,5 +1,6 @@ package io.github.devcavin.gatelog.visitors.dto +import io.github.devcavin.gatelog.common.time.TimeUtil import io.github.devcavin.gatelog.visitors.Visit import io.github.devcavin.gatelog.visitors.VisitorProfile import java.time.OffsetDateTime @@ -22,8 +23,9 @@ data class VisitResponse( val zoneName: String?, val createdById: UUID, val createdByName: String, - val checkInTime: OffsetDateTime, - val checkOutTime: OffsetDateTime? + val checkInTime: OffsetDateTime?, + val checkOutTime: OffsetDateTime?, + val overnight: Boolean ) data class VisitorProfileResponse( @@ -46,7 +48,7 @@ data class VisitSummary( val status: String, val zoneId: UUID?, val zoneName: String?, - val checkInTime: OffsetDateTime, + val checkInTime: OffsetDateTime?, val checkOutTime: OffsetDateTime? ) @@ -69,7 +71,9 @@ fun Visit.toResponse(): VisitResponse { createdById = requireNotNull(createdBy.id), createdByName = createdBy.name, checkInTime = checkInTime, - checkOutTime = checkOutTime + checkOutTime = checkOutTime, + overnight = checkOutTime == null && + TimeUtil.isOvernight(checkInTime) ) } diff --git a/src/main/kotlin/io/github/devcavin/gatelog/zones/ZoneController.kt b/src/main/kotlin/io/github/devcavin/gatelog/zones/ZoneController.kt index 7a967f2..0a2558b 100644 --- a/src/main/kotlin/io/github/devcavin/gatelog/zones/ZoneController.kt +++ b/src/main/kotlin/io/github/devcavin/gatelog/zones/ZoneController.kt @@ -25,7 +25,7 @@ class ZoneController( ) { @PostMapping - @PreAuthorize("hasAnyRole('SUPER_ADMIN', 'MANAGER')") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')") fun create( @AuthenticationPrincipal requestedBy: User, @PathVariable siteId: UUID, @@ -34,8 +34,19 @@ class ZoneController( ResponseEntity.status(HttpStatus.CREATED) .body(zoneService.create(requestedBy, siteId, request)) + @GetMapping("/{zoneId}") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER', 'STAFF')") + fun getById( + @AuthenticationPrincipal requestedBy: User, + @PathVariable siteId: UUID, + @PathVariable zoneId: UUID + ): ResponseEntity = + ResponseEntity.ok( + zoneService.getById(requestedBy, siteId, zoneId) + ) + @GetMapping - @PreAuthorize("hasAnyRole('SUPER_ADMIN', 'MANAGER', 'STAFF')") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER', 'STAFF')") fun getAllBySite( @AuthenticationPrincipal requestedBy: User, @PathVariable siteId: UUID @@ -43,7 +54,7 @@ class ZoneController( ResponseEntity.ok(zoneService.getAllBySite(requestedBy, siteId)) @PutMapping("/{zoneId}") - @PreAuthorize("hasAnyRole('SUPER_ADMIN', 'MANAGER')") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')") fun update( @AuthenticationPrincipal requestedBy: User, @PathVariable siteId: UUID, @@ -53,7 +64,7 @@ class ZoneController( ResponseEntity.ok(zoneService.update(requestedBy, siteId, zoneId, request)) @DeleteMapping("/{zoneId}") - @PreAuthorize("hasAnyRole('SUPER_ADMIN', 'MANAGER')") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')") fun delete( @AuthenticationPrincipal requestedBy: User, @PathVariable siteId: UUID, diff --git a/src/main/kotlin/io/github/devcavin/gatelog/zones/ZoneRepository.kt b/src/main/kotlin/io/github/devcavin/gatelog/zones/ZoneRepository.kt index 20e2c73..7e4c158 100644 --- a/src/main/kotlin/io/github/devcavin/gatelog/zones/ZoneRepository.kt +++ b/src/main/kotlin/io/github/devcavin/gatelog/zones/ZoneRepository.kt @@ -6,6 +6,7 @@ import java.util.UUID @Repository interface ZoneRepository : JpaRepository { + fun findByIdAndSiteId(id: UUID, siteId: UUID): Zone? fun findAllBySiteId(siteId: UUID): List fun existsBySiteIdAndName(siteId: UUID, name: String): Boolean } \ No newline at end of file diff --git a/src/main/kotlin/io/github/devcavin/gatelog/zones/ZoneService.kt b/src/main/kotlin/io/github/devcavin/gatelog/zones/ZoneService.kt index 8e4e63b..1649b17 100644 --- a/src/main/kotlin/io/github/devcavin/gatelog/zones/ZoneService.kt +++ b/src/main/kotlin/io/github/devcavin/gatelog/zones/ZoneService.kt @@ -18,19 +18,27 @@ class ZoneService( private val siteRepository: SiteRepository, private val authorizationService: AuthorizationService ) { + @Transactional fun create( requestedBy: User, siteId: UUID, request: ZoneRequest ): ZoneResponse { - authorizationService.assertCovers(requestedBy, siteId) + authorizationService.assertCovers( + requestedBy, + siteId + ) val site = siteRepository.findById(siteId) - .orElseThrow { ResourceNotFoundException("Site", siteId) } + .orElseThrow { + ResourceNotFoundException("Site", siteId) + } if (zoneRepository.existsBySiteIdAndName(siteId, request.name)) { - throw ConflictException("Zone with this name already exists under this site") + throw ConflictException( + "Zone with this name already exists under this site" + ) } val zone = Zone( @@ -38,7 +46,9 @@ class ZoneService( site = site ) - return zoneRepository.save(zone).toResponse() + return zoneRepository + .save(zone) + .toResponse() } @Transactional(readOnly = true) @@ -46,13 +56,31 @@ class ZoneService( requestedBy: User, siteId: UUID ): List { - authorizationService.assertCovers(requestedBy, siteId) + authorizationService.assertCovers( + requestedBy, + siteId + ) - if (!siteRepository.existsById(siteId)) { - throw ResourceNotFoundException("Site", siteId) - } + return zoneRepository + .findAllBySiteId(siteId) + .map { it.toResponse() } + } + + @Transactional(readOnly = true) + fun getById( + requestedBy: User, + siteId: UUID, + zoneId: UUID + ): ZoneResponse { + authorizationService.assertCovers( + requestedBy, + siteId + ) - return zoneRepository.findAllBySiteId(siteId).map { it.toResponse() } + return findZone( + siteId, + zoneId + ).toResponse() } @Transactional @@ -62,32 +90,62 @@ class ZoneService( zoneId: UUID, request: ZoneRequest ): ZoneResponse { - authorizationService.assertCovers(requestedBy, siteId) - - val zone = zoneRepository.findById(zoneId).orElseThrow { ResourceNotFoundException("Zone", zoneId) } + authorizationService.assertCovers( + requestedBy, + siteId + ) - if (zone.site.id != siteId) throw ResourceNotFoundException("Zone", zoneId) + val zone = findZone( + siteId, + zoneId + ) - if (zone.name != request.name && zoneRepository.existsBySiteIdAndName(siteId, request.name)) { - throw ConflictException("Zone with this name already exists under this site") + if ( + zone.name != request.name && + zoneRepository.existsBySiteIdAndName( + siteId, + request.name + ) + ) { + throw ConflictException( + "Zone with this name already exists under this site" + ) } zone.name = request.name - return zoneRepository.save(zone).toResponse() + + return zoneRepository + .save(zone) + .toResponse() } @Transactional fun delete( requestedBy: User, siteId: UUID, - zoneId: UUID) { - authorizationService.assertCovers(requestedBy, siteId) - - val zone = zoneRepository.findById(zoneId) - .orElseThrow { ResourceNotFoundException("Zone", zoneId) } + zoneId: UUID + ) { + authorizationService.assertCovers( + requestedBy, + siteId + ) - if (zone.site.id != siteId) throw ResourceNotFoundException("Zone", zoneId) + val zone = findZone( + siteId, + zoneId + ) zoneRepository.delete(zone) } + + private fun findZone( + siteId: UUID, + zoneId: UUID + ): Zone = + zoneRepository + .findByIdAndSiteId(zoneId, siteId) + ?: throw ResourceNotFoundException( + "Zone", + zoneId + ) } \ No newline at end of file