diff --git a/backend/src/main/kotlin/io/github/devcavin/backend/service/AuthService.kt b/backend/src/main/kotlin/io/github/devcavin/backend/service/AuthService.kt index 13b9b3d..8c6e9fd 100644 --- a/backend/src/main/kotlin/io/github/devcavin/backend/service/AuthService.kt +++ b/backend/src/main/kotlin/io/github/devcavin/backend/service/AuthService.kt @@ -11,9 +11,9 @@ import io.github.devcavin.backend.security.JwtTokenProvider import io.github.devcavin.backend.web.dto.auth.AuthResponse import io.github.devcavin.backend.web.dto.auth.AuthenticatedUser import io.github.devcavin.backend.web.dto.auth.LoginRequest -import jakarta.transaction.Transactional import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional import java.time.OffsetDateTime import java.util.UUID diff --git a/backend/src/main/kotlin/io/github/devcavin/backend/service/DashboardService.kt b/backend/src/main/kotlin/io/github/devcavin/backend/service/DashboardService.kt new file mode 100644 index 0000000..a99df41 --- /dev/null +++ b/backend/src/main/kotlin/io/github/devcavin/backend/service/DashboardService.kt @@ -0,0 +1,93 @@ +package io.github.devcavin.backend.service + +import io.github.devcavin.backend.domain.model.User +import io.github.devcavin.backend.domain.repository.VisitStatusRepository +import io.github.devcavin.backend.domain.repository.VisitorRepository +import io.github.devcavin.backend.web.dto.dashboard.DashboardFeed +import io.github.devcavin.backend.web.dto.dashboard.DashboardSummary +import io.github.devcavin.backend.web.dto.visitor.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 + +@Service +class DashboardService( + private val visitorRepository: VisitorRepository, + private val visitorStatusRepository: VisitStatusRepository, + + @Value("\${gatelog.scheduler.overdue-threshold-hours:2}") + private val overdueThresholdHours: Long, +) { + @Transactional(readOnly = true) + fun getFeed(requestedBy: User): DashboardFeed { + val siteId = requestedBy.site.id!! + 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")!! + + // summary counts bar + val currentlyOnPremises = visitorRepository.countBySiteIdAndVisitStatus( + siteId = siteId, + visitStatus = checkedInStatus + ) + + val checkedInToday = visitorRepository.findAllCheckedInToday( + siteId = siteId, + startOfDay = startOfDay, + endOfDay = endOfDay, + pageable = PageRequest.of(0, 1) + ).totalElements + + val checkedOutToday = visitorRepository.findAllBySiteIdAndVisitStatus( + siteId = siteId, + visitStatus = checkedOutStatus, + pageable = PageRequest.of(0, 1) + ).totalElements + + val overdueCount = visitorRepository.findAllBySiteIdAndVisitStatus( + siteId = siteId, + visitStatus = overdueStatus, + pageable = PageRequest.of(0, 1) + ).totalElements + + val activeVisitors = visitorRepository.findAllBySiteIdAndVisitStatus( + siteId = siteId, + visitStatus = checkedInStatus, + pageable = PageRequest.of(0, 10, + Sort.by(Sort.Direction.DESC, "checkoutTime")) + ).content.map { it.toResponse() } + + val overdueVisitors = visitorRepository.findAllOverdue( + siteId = siteId, + threshold = overdueThreshold + ).map { it.toResponse() } + + val recentlyCheckedOut = visitorRepository.findAllBySiteIdAndVisitStatus( + siteId = siteId, + visitStatus = checkedOutStatus, + pageable = PageRequest.of(0, 10, + Sort.by(Sort.Direction.DESC, "checkoutTime")) + ).content.map { it.toResponse() } + + return DashboardFeed( + summary = DashboardSummary( + currentlyOnPremises = currentlyOnPremises, + checkedInToday = checkedInToday, + checkedOutToday = checkedOutToday, + overdueCount = overdueCount + ), + activeVisitors = activeVisitors, + overdueVisitors = overdueVisitors, + recentlyCheckedOut = recentlyCheckedOut + ) + } +} \ No newline at end of file diff --git a/backend/src/main/kotlin/io/github/devcavin/backend/service/OverdueVisitorJob.kt b/backend/src/main/kotlin/io/github/devcavin/backend/service/OverdueVisitorJob.kt new file mode 100644 index 0000000..5af8c22 --- /dev/null +++ b/backend/src/main/kotlin/io/github/devcavin/backend/service/OverdueVisitorJob.kt @@ -0,0 +1,56 @@ +package io.github.devcavin.backend.service + +import io.github.devcavin.backend.common.exception.ResourceNotFoundException +import io.github.devcavin.backend.domain.repository.SiteRepository +import io.github.devcavin.backend.domain.repository.VisitStatusRepository +import io.github.devcavin.backend.domain.repository.VisitorRepository +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 OverdueVisitorJob( + private val visitorRepository: VisitorRepository, + private val visitorStatusRepository: VisitStatusRepository, + private val siteRepository: SiteRepository, + + @Value("\${gatelog.scheduler.overdue-threshold-hours:2}") + private val overdueThresholdHours: Long +) { + private val log = LoggerFactory.getLogger(OverdueVisitorJob::class.java) + + // runs scheduler every 15 mins + @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 sites = siteRepository.findAll() + + var totalFlagged = 0 + + sites.forEach { site -> + val flagged = visitorRepository.markOverdue( + siteId = site.id!!, + threshold = threshold, + overdueStatus = overdueStatus + ) + + if (flagged > 0) { + log.info("Flagged $flagged overdue visitor(s) at site $site") + } + + totalFlagged += flagged + } + + if (totalFlagged > 0) { + log.info("Overdue job complete - $totalFlagged visitor(s) flagged") + } + } +} \ No newline at end of file diff --git a/backend/src/main/kotlin/io/github/devcavin/backend/service/UserService.kt b/backend/src/main/kotlin/io/github/devcavin/backend/service/UserService.kt index 1211e22..6333820 100644 --- a/backend/src/main/kotlin/io/github/devcavin/backend/service/UserService.kt +++ b/backend/src/main/kotlin/io/github/devcavin/backend/service/UserService.kt @@ -11,10 +11,10 @@ import io.github.devcavin.backend.domain.repository.UserRepository import io.github.devcavin.backend.web.dto.user.CreateUserRequest import io.github.devcavin.backend.web.dto.user.UserResponse import io.github.devcavin.backend.web.dto.user.toResponse -import jakarta.transaction.Transactional import org.springframework.security.access.AccessDeniedException import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional import java.util.UUID @Service diff --git a/backend/src/main/kotlin/io/github/devcavin/backend/web/controller/DashboardController.kt b/backend/src/main/kotlin/io/github/devcavin/backend/web/controller/DashboardController.kt new file mode 100644 index 0000000..3704e2f --- /dev/null +++ b/backend/src/main/kotlin/io/github/devcavin/backend/web/controller/DashboardController.kt @@ -0,0 +1,27 @@ +package io.github.devcavin.backend.web.controller + +import io.github.devcavin.backend.domain.model.User +import io.github.devcavin.backend.service.DashboardService +import io.github.devcavin.backend.web.dto.dashboard.DashboardFeed +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/dashboard") +class DashboardController( + private val dashboardService: DashboardService +) { + @GetMapping + fun getFeed( + @AuthenticationPrincipal requestedBy: User, + ): ResponseEntity { + val feed = dashboardService.getFeed(requestedBy) + + return ResponseEntity.status(HttpStatus.OK) + .body(feed) + } +} \ No newline at end of file diff --git a/backend/src/main/kotlin/io/github/devcavin/backend/web/dto/dashboard/DashboardDTOs.kt b/backend/src/main/kotlin/io/github/devcavin/backend/web/dto/dashboard/DashboardDTOs.kt new file mode 100644 index 0000000..c666beb --- /dev/null +++ b/backend/src/main/kotlin/io/github/devcavin/backend/web/dto/dashboard/DashboardDTOs.kt @@ -0,0 +1,19 @@ +package io.github.devcavin.backend.web.dto.dashboard + +import io.github.devcavin.backend.web.dto.visitor.VisitorResponse +import java.time.OffsetDateTime + +data class DashboardSummary( + val currentlyOnPremises: Long, + val checkedInToday: Long, + val checkedOutToday: Long, + val overdueCount: Long, + val asOf: OffsetDateTime = OffsetDateTime.now() +) + +data class DashboardFeed( + val summary: DashboardSummary, + val activeVisitors: List, + val overdueVisitors: List, + val recentlyCheckedOut: List +) \ No newline at end of file diff --git a/backend/src/main/resources/application.yaml b/backend/src/main/resources/application.yaml index 1555f05..3a503ee 100644 --- a/backend/src/main/resources/application.yaml +++ b/backend/src/main/resources/application.yaml @@ -19,6 +19,9 @@ spring: hibernate: format_sql: true dialect: org.hibernate.dialect.PostgreSQLDialect + jdbc: + batch_size: 50 + open-in-view: false flyway: enabled: true @@ -39,4 +42,8 @@ gatelog: refresh-token-expiry-days: ${JWT_REFRESH_TOKEN_EXPIRY_DAYS:7} cors: - allowed-origins: ${CORS_ALLOWED_ORIGINS} \ No newline at end of file + allowed-origins: ${CORS_ALLOWED_ORIGINS} + + scheduler: + overdue-threshold-hours: 2 + overdue-job-rate-ms: 900000 \ No newline at end of file