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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ class Visitor(
@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)
@JoinColumn(name = "site_id", nullable = false)
var site: Site,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package io.github.devcavin.backend.domain.model

import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.FetchType
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import jakarta.persistence.JoinColumn
import jakarta.persistence.ManyToOne
import jakarta.persistence.Table
import jakarta.persistence.UniqueConstraint
import org.hibernate.annotations.CreationTimestamp
import org.hibernate.annotations.UpdateTimestamp
import java.time.OffsetDateTime
import java.util.UUID

@Entity
@Table(
name = "visitor_profiles",
uniqueConstraints = [UniqueConstraint(
name = "uq_visitor_profiles_phone_site",
columnNames = ["phone_number", "site_id"]
)]
)
class VisitorProfile(
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Column(updatable = false, nullable = false)
override var id: UUID? = null,

@Column(nullable = false)
var name: String,

@Column(nullable = false, name = "phone_number", length = 25)
var phoneNumber: String,

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "site_id", nullable = false)
var site: Site,

@CreationTimestamp
@Column(name = "created_at", updatable = false, nullable = false)
var createdAt: OffsetDateTime = OffsetDateTime.now(),

@UpdateTimestamp
@Column(name = "updated_at", nullable = false)
var updatedAt: OffsetDateTime = OffsetDateTime.now()
) : BaseEntity()
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package io.github.devcavin.backend.domain.repository

import io.github.devcavin.backend.domain.model.VisitorProfile
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
import java.util.UUID

@Repository
interface VisitorProfileRepository : JpaRepository<VisitorProfile, UUID> {
fun findBySiteIdAndPhoneNumber(siteId: UUID, phoneNumber: String): VisitorProfile?
fun existsBySiteIdAndPhoneNumber(siteId: UUID, phoneNumber: String): Boolean
}
Original file line number Diff line number Diff line change
Expand Up @@ -81,5 +81,7 @@ interface VisitorRepository : JpaRepository<Visitor, UUID>, JpaSpecificationExec
visitStatus: VisitStatus
): Long

fun countBySiteIdAndVisitorProfileId(siteId: UUID, profileId: UUID): Long

// fun findAll(specification: Specification<VisitorSpecification>, pageable: Pageable): Page<Visitor>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package io.github.devcavin.backend.service

import io.github.devcavin.backend.domain.model.User
import io.github.devcavin.backend.domain.model.Visitor
import io.github.devcavin.backend.domain.repository.VisitorRepository
import io.github.devcavin.backend.domain.repository.VisitorSpecification
import io.github.devcavin.backend.web.dto.visitor.VisitorSearchParams
import org.apache.tomcat.util.http.fileupload.ByteArrayOutputStream
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.io.PrintWriter
import java.time.Duration
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter

@Service
class ReportService(
private val visitorRepository: VisitorRepository
) {
private val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd").withZone(ZoneOffset.UTC)

@Transactional(readOnly = true)
fun exportVisitorsCsv(
requestedBy: User,
searchParams: VisitorSearchParams
): ByteArray {
val spec = VisitorSpecification.search(
siteId = requestedBy.site.id!!,
params = searchParams
)

val visitors = visitorRepository.findAll(spec)

return buildCsv(visitors)
}

private fun buildCsv(visitors: List<Visitor>): ByteArray {
val output = ByteArrayOutputStream()
val writer = PrintWriter(output)

writer.println(
csvRow (
"ID", "Name", "Phone", "Visitor Type",
"Purpose", "Status", "Zone", "Host",
"Registered By", "Check In", "Check Out", "Duration (minutes)"
)
)

visitors.forEach { visitor ->
val durationInMinutes = visitor.checkOutTime?.let {
Duration.between(visitor.checkInTime, it).toMinutes().toString()
} ?: ""

writer.println(
csvRow(
visitor.id.toString(),
visitor.name,
visitor.phone,
visitor.visitorType,
visitor.purpose,
visitor.visitStatus.name,
visitor.zone?.name ?: "",
visitor.createdBy.name,
formatter.format(visitor.checkInTime),
visitor.checkOutTime?.let { formatter.format(it) } ?: "",
durationInMinutes
)
)
}

writer.flush()
return output.toByteArray()
}

private fun csvRow(vararg fields: String): String =
fields.joinToString(",") { field ->
'"' + field.replace("\"", "\"\"") + '"'
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package io.github.devcavin.backend.service

import io.github.devcavin.backend.common.exception.ConflictException
import io.github.devcavin.backend.common.exception.ResourceNotFoundException
import io.github.devcavin.backend.domain.repository.SiteRepository
import io.github.devcavin.backend.web.dto.site.SiteRequest
import io.github.devcavin.backend.web.dto.site.SiteResponse
import io.github.devcavin.backend.web.dto.site.toEntity
import io.github.devcavin.backend.web.dto.site.toResponse
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.util.UUID

@Service
class SiteService(
private val siteRepository: SiteRepository
) {
@Transactional
fun create(request: SiteRequest): SiteResponse {
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()
}

@Transactional(readOnly = true)
fun getAll(): List<SiteResponse> = siteRepository.findAll().map { it.toResponse() }

@Transactional(readOnly = true)
fun getById(id: UUID): SiteResponse {
val site = siteRepository.findById(id)
.orElseThrow { ResourceNotFoundException("Site", id) }
return site.toResponse()
}

@Transactional
fun update(id: UUID, request: SiteRequest): SiteResponse {
val site = siteRepository.findById(id)
.orElseThrow { ResourceNotFoundException("Site", id) }

site.name = request.name
site.location = request.location

return siteRepository.save(site).toResponse()
}

@Transactional
fun delete(id: UUID) {
if (!siteRepository.existsById(id)) throw ResourceNotFoundException("Site", id)

return siteRepository.deleteById(id)
}
}
Original file line number Diff line number Diff line change
@@ -1,21 +1,23 @@
package io.github.devcavin.backend.service

import io.github.devcavin.backend.common.exception.ConflictException
import io.github.devcavin.backend.common.exception.InvalidCredentialsException
import io.github.devcavin.backend.common.exception.InvalidStateException
import io.github.devcavin.backend.common.exception.ResourceNotFoundException
import io.github.devcavin.backend.domain.model.Role
import io.github.devcavin.backend.domain.model.Site
import io.github.devcavin.backend.domain.model.User
import io.github.devcavin.backend.domain.repository.RoleRepository
import io.github.devcavin.backend.domain.repository.SiteRepository
import io.github.devcavin.backend.domain.repository.UserRepository
import io.github.devcavin.backend.web.dto.user.ChangePasswordRequest
import io.github.devcavin.backend.web.dto.user.CreateUserRequest
import io.github.devcavin.backend.web.dto.user.UpdateUserRequest
import io.github.devcavin.backend.web.dto.user.UserResponse
import io.github.devcavin.backend.web.dto.user.toResponse
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
import java.util.*

@Service
class UserService(
Expand Down Expand Up @@ -46,18 +48,139 @@ class UserService(
return savedUser.toResponse()
}

@Transactional(readOnly = true)
fun getAll(requestedBy: User): List<UserResponse> {
return when (requestedBy.role.name) {
"SUPER_ADMIN" -> userRepository.findAll().map { it.toResponse() }
else -> userRepository
.findAllBySiteId(requestedBy.site.id!!)
.map { it.toResponse() }
}
}

@Transactional(readOnly = true)
fun getById(requestedBy: User, userId: UUID): UserResponse {
val user = userRepository.findById(userId)
.orElseThrow { ResourceNotFoundException("User", userId) }
enforceSiteBoundary(requestedBy, user)
return user.toResponse()
}

@Transactional
fun updateUser(
requestedBy: User,
userId: UUID,
request: UpdateUserRequest
): UserResponse {
val target = userRepository.findById(userId)
.orElseThrow { ResourceNotFoundException("User", userId) }

enforceSiteBoundary(requestedBy, target)
enforceUpdateRules(requestedBy, target, 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)

target.name = request.name
target.email = request.email
target.role = newRole

return userRepository.save(target).toResponse()
}

@Transactional
fun deactivate(requestedBy: User, userId: UUID): UserResponse {
if (requestedBy.id == userId) {
throw InvalidStateException("You cannot deactivate your own account")
}
val target = userRepository.findById(userId)
.orElseThrow { ResourceNotFoundException("User", userId) }

enforceSiteBoundary(requestedBy, target)
enforceDeactivationRules(requestedBy, target)

target.isActive = false
return userRepository.save(target).toResponse()
}

@Transactional
fun activate(requestedBy: User, userId: UUID): UserResponse {
val target = userRepository.findById(userId)
.orElseThrow { ResourceNotFoundException("User", userId) }
enforceSiteBoundary(requestedBy, target)
target.isActive = true
return userRepository.save(target).toResponse()
}

@Transactional
fun changePassword(
requestedBy: User,
request: ChangePasswordRequest
): UserResponse {
if (!passwordEncoder.matches(
request.currentPassword, requestedBy.passwordHash
)
) {
throw InvalidCredentialsException()
}
requestedBy.passwordHash = passwordEncoder.encode(request.newPassword)
return userRepository.save(requestedBy).toResponse()
}

private fun enforceCreationRules(
requestedBy: User,
targetRoleName: String,
targetSiteId: UUID
) {
when (requestedBy.role.name) {
"SUPER_ADMIN" -> {} // Unrestricted
"SUPER_ADMIN" -> Unit
"MANAGER" -> {
if (targetRoleName != "STAFF") throw AccessDeniedException("Managers can only create STAFF accounts")
if (targetSiteId != requestedBy.site.id) throw AccessDeniedException("Managers can only create users at their own site")
}
else -> throw AccessDeniedException("Insufficient privileges to create users")
}
}

private fun enforceSiteBoundary(requestedBy: User, target: User) {
if (requestedBy.role.name != "SUPER_ADMIN" &&
requestedBy.site.id != target.site.id
) {
throw AccessDeniedException("User does not belong to your site")
}
}

private fun enforceDeactivationRules(requestedBy: User, target: User) {
when (requestedBy.role.name) {
"SUPER_ADMIN" -> Unit
"MANAGER" -> {
if (target.role.name != "STAFF")
throw AccessDeniedException("Managers can only deactivate Staff accounts")
}
else -> throw AccessDeniedException("Insufficient privilege to deactivate users")
}
}

private fun enforceUpdateRules(
requestedBy: User,
target: User,
newRoleName: String
) {
when (requestedBy.role.name) {
"SUPER_ADMIN" -> Unit
"MANAGER" -> {
if (target.role.name != "STAFF")
throw AccessDeniedException("Managers can only update Staff accounts")
if (newRoleName != "STAFF")
throw AccessDeniedException("Managers cannot change role beyond Staff")
}
else -> throw AccessDeniedException("Insufficient privilege to update users")
}
}
}
Loading
Loading