diff --git a/backend/src/main/kotlin/io/github/devcavin/backend/domain/model/Visitor.kt b/backend/src/main/kotlin/io/github/devcavin/backend/domain/model/Visitor.kt index 0509aaa..b7d4214 100644 --- a/backend/src/main/kotlin/io/github/devcavin/backend/domain/model/Visitor.kt +++ b/backend/src/main/kotlin/io/github/devcavin/backend/domain/model/Visitor.kt @@ -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, diff --git a/backend/src/main/kotlin/io/github/devcavin/backend/domain/model/VisitorProfile.kt b/backend/src/main/kotlin/io/github/devcavin/backend/domain/model/VisitorProfile.kt new file mode 100644 index 0000000..24b9853 --- /dev/null +++ b/backend/src/main/kotlin/io/github/devcavin/backend/domain/model/VisitorProfile.kt @@ -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() \ No newline at end of file diff --git a/backend/src/main/kotlin/io/github/devcavin/backend/domain/repository/VisitorProfileRepository.kt b/backend/src/main/kotlin/io/github/devcavin/backend/domain/repository/VisitorProfileRepository.kt new file mode 100644 index 0000000..e04cb16 --- /dev/null +++ b/backend/src/main/kotlin/io/github/devcavin/backend/domain/repository/VisitorProfileRepository.kt @@ -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 { + fun findBySiteIdAndPhoneNumber(siteId: UUID, phoneNumber: String): VisitorProfile? + fun existsBySiteIdAndPhoneNumber(siteId: UUID, phoneNumber: String): Boolean +} \ No newline at end of file diff --git a/backend/src/main/kotlin/io/github/devcavin/backend/domain/repository/VisitorRepository.kt b/backend/src/main/kotlin/io/github/devcavin/backend/domain/repository/VisitorRepository.kt index e2b6f82..24ad01e 100644 --- a/backend/src/main/kotlin/io/github/devcavin/backend/domain/repository/VisitorRepository.kt +++ b/backend/src/main/kotlin/io/github/devcavin/backend/domain/repository/VisitorRepository.kt @@ -81,5 +81,7 @@ interface VisitorRepository : JpaRepository, JpaSpecificationExec visitStatus: VisitStatus ): Long + fun countBySiteIdAndVisitorProfileId(siteId: UUID, profileId: UUID): Long + // fun findAll(specification: Specification, pageable: Pageable): Page } \ No newline at end of file diff --git a/backend/src/main/kotlin/io/github/devcavin/backend/service/ReportService.kt b/backend/src/main/kotlin/io/github/devcavin/backend/service/ReportService.kt new file mode 100644 index 0000000..778cae2 --- /dev/null +++ b/backend/src/main/kotlin/io/github/devcavin/backend/service/ReportService.kt @@ -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): 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("\"", "\"\"") + '"' + } +} \ No newline at end of file diff --git a/backend/src/main/kotlin/io/github/devcavin/backend/service/SiteService.kt b/backend/src/main/kotlin/io/github/devcavin/backend/service/SiteService.kt new file mode 100644 index 0000000..41452da --- /dev/null +++ b/backend/src/main/kotlin/io/github/devcavin/backend/service/SiteService.kt @@ -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 = 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) + } +} \ 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 6333820..0f3a3d1 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 @@ -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( @@ -46,13 +48,98 @@ class UserService( return savedUser.toResponse() } + @Transactional(readOnly = true) + fun getAll(requestedBy: User): List { + 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") @@ -60,4 +147,40 @@ class UserService( 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") + } + } } \ No newline at end of file diff --git a/backend/src/main/kotlin/io/github/devcavin/backend/service/VisitorService.kt b/backend/src/main/kotlin/io/github/devcavin/backend/service/VisitorService.kt index 10c6836..d63c27d 100644 --- a/backend/src/main/kotlin/io/github/devcavin/backend/service/VisitorService.kt +++ b/backend/src/main/kotlin/io/github/devcavin/backend/service/VisitorService.kt @@ -1,30 +1,25 @@ package io.github.devcavin.backend.service +import io.github.devcavin.backend.common.exception.ConflictException import io.github.devcavin.backend.common.exception.InvalidStateException import io.github.devcavin.backend.common.exception.ResourceNotFoundException import io.github.devcavin.backend.domain.model.User import io.github.devcavin.backend.domain.model.Visitor -import io.github.devcavin.backend.domain.repository.VisitStatusRepository -import io.github.devcavin.backend.domain.repository.VisitorRepository -import io.github.devcavin.backend.domain.repository.VisitorSpecification -import io.github.devcavin.backend.domain.repository.ZoneRepository -import io.github.devcavin.backend.web.dto.visitor.RegisterVisitorRequest -import io.github.devcavin.backend.web.dto.visitor.ReturningVisitorResponse -import io.github.devcavin.backend.web.dto.visitor.VisitorResponse -import io.github.devcavin.backend.web.dto.visitor.VisitorSearchParams -import io.github.devcavin.backend.web.dto.visitor.toResponse -import io.github.devcavin.backend.web.dto.visitor.toReturningResponse +import io.github.devcavin.backend.domain.model.VisitorProfile +import io.github.devcavin.backend.domain.repository.* +import io.github.devcavin.backend.web.dto.visitor.* 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 +import java.util.* @Service class VisitorService( private val visitorRepository: VisitorRepository, + private val visitorProfileRepository: VisitorProfileRepository, private val visitStatusRepository: VisitStatusRepository, private val zoneRepository: ZoneRepository ) { @@ -41,15 +36,26 @@ class VisitorService( zoneRepository.findById(it) .orElseThrow { ResourceNotFoundException("Zone", it) } .also { z -> - if (z.site.id != requestedBy.site.id) { + if (z.site.id != requestedBy.site.id) throw AccessDeniedException("Zone does not belong to your site") - } } } + // find or create visitor profile by phone + site + val profile = visitorProfileRepository + .findBySiteIdAndPhoneNumber(requestedBy.site.id!!, request.phone) + ?: visitorProfileRepository.save( + VisitorProfile( + name = request.name, + phoneNumber = request.phone, + site = requestedBy.site + ) + ) + val visitor = Visitor( name = request.name, phone = request.phone, + visitorProfile = profile, site = requestedBy.site, zone = zone, createdBy = requestedBy, @@ -102,14 +108,28 @@ class VisitorService( return visitorRepository.findAll(spec, pageable).map { it.toResponse() } } + @Transactional(readOnly = true) fun findReturningVisitor( requestedBy: User, phone: String ): ReturningVisitorResponse? { - return visitorRepository - .findTopBySiteIdAndPhoneOrderByCheckInTimeDesc(requestedBy.site.id!!, phone) - ?.toReturningResponse() + val profile = visitorProfileRepository + .findBySiteIdAndPhoneNumber(requestedBy.site.id!!, phone) + ?: return null + + val lastVisit = visitorRepository + .findTopBySiteIdAndPhoneOrderByCheckInTimeDesc( + requestedBy.site.id!!, phone + ) + + return ReturningVisitorResponse( + name = profile.name, + phone = profile.phoneNumber, + visitorType = lastVisit?.visitorType ?: "", + zoneId = lastVisit?.zone?.id, + zoneName = lastVisit?.zone?.name + ) } private fun enforcesSiteBoundary(requestedBy: User, visitorSiteId: UUID) { @@ -119,4 +139,43 @@ class VisitorService( throw AccessDeniedException("Visitor does not belong to your site") } } + + @Transactional + fun updateProfile( + requestedBy: User, + profileId: UUID, + request: UpdateVisitorProfileRequest + ): VisitorProfileResponse { + val profile = visitorProfileRepository.findById(profileId) + .orElseThrow { ResourceNotFoundException("VisitorProfile", profileId) } + + if (profile.site.id != requestedBy.site.id) { + throw AccessDeniedException("Profile does not belong to your site") + } + + if (request.phoneNumber != profile.phoneNumber && + visitorProfileRepository.existsBySiteIdAndPhoneNumber( + requestedBy.site.id!!, request.phoneNumber + ) + ) { + throw ConflictException( + "Phone number is already registered at this site" + ) + } + + profile.name = request.name + profile.phoneNumber = request.phoneNumber + + val saved = visitorProfileRepository.save(profile) + val visitCount = visitorRepository + .countBySiteIdAndVisitorProfileId(requestedBy.site.id!!, profileId) + + return VisitorProfileResponse( + id = saved.id!!, + name = saved.name, + phoneNumber = saved.phoneNumber, + siteId = saved.site.id!!, + visitCount = visitCount.toInt() + ) + } } \ No newline at end of file diff --git a/backend/src/main/kotlin/io/github/devcavin/backend/service/ZoneService.kt b/backend/src/main/kotlin/io/github/devcavin/backend/service/ZoneService.kt new file mode 100644 index 0000000..107db4c --- /dev/null +++ b/backend/src/main/kotlin/io/github/devcavin/backend/service/ZoneService.kt @@ -0,0 +1,69 @@ +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.model.Zone +import io.github.devcavin.backend.domain.repository.SiteRepository +import io.github.devcavin.backend.domain.repository.ZoneRepository +import io.github.devcavin.backend.web.dto.zone.ZoneRequest +import io.github.devcavin.backend.web.dto.zone.ZoneResponse +import io.github.devcavin.backend.web.dto.zone.toResponse +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.util.UUID + +@Service +class ZoneService( + private val zoneRepository: ZoneRepository, + private val siteRepository: SiteRepository +) { + @Transactional + fun create(siteId: UUID, request: ZoneRequest): ZoneResponse { + val site = siteRepository.findById(siteId) + .orElseThrow { ResourceNotFoundException("Site", siteId) } + + if (!zoneRepository.existsBySiteIdAndName(siteId, request.name)) { + throw ConflictException("Zone with this name already exists under this site") + } + + val zone = Zone( + name = request.name, + site = site + ) + + return zoneRepository.save(zone).toResponse() + } + + @Transactional(readOnly = true) + fun getAllBySite(siteId: UUID): List { + if (!siteRepository.existsById(siteId)) { + throw ResourceNotFoundException("Site", siteId) + } + + return zoneRepository.findAllBySiteId(siteId).map { it.toResponse() } + } + + @Transactional + fun update(siteId: UUID, zoneId: UUID, request: ZoneRequest): ZoneResponse { + val zone = zoneRepository.findById(zoneId).orElseThrow { ResourceNotFoundException("Zone", zoneId) } + + if (zone.site.id != siteId) throw ResourceNotFoundException("Zone", zoneId) + + 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() + } + + @Transactional + fun delete(zoneId: UUID, siteId: UUID) { + val zone = zoneRepository.findById(zoneId) + .orElseThrow { ResourceNotFoundException("Zone", zoneId) } + + if (zone.site.id != siteId) throw ResourceNotFoundException("Zone", zoneId) + + zoneRepository.delete(zone) + } +} \ No newline at end of file diff --git a/backend/src/main/kotlin/io/github/devcavin/backend/web/controller/ReportController.kt b/backend/src/main/kotlin/io/github/devcavin/backend/web/controller/ReportController.kt new file mode 100644 index 0000000..92f0962 --- /dev/null +++ b/backend/src/main/kotlin/io/github/devcavin/backend/web/controller/ReportController.kt @@ -0,0 +1,65 @@ +package io.github.devcavin.backend.web.controller + +import io.github.devcavin.backend.domain.model.User +import io.github.devcavin.backend.service.ReportService +import io.github.devcavin.backend.web.dto.visitor.VisitorSearchParams +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType +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.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import java.time.LocalDate +import java.time.OffsetDateTime +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import java.util.UUID + +@RestController +@RequestMapping("/api/reports") +class ReportController(private val reportService: ReportService) { + @GetMapping("/visitors/csv") + @PreAuthorize("hasAnyRole('SUPER_ADMIN', 'MANAGER')") + fun exportVisitorsCsv( + @AuthenticationPrincipal requestedBy: User, + @RequestParam(required = false) from: OffsetDateTime?, + @RequestParam(required = false) to: OffsetDateTime?, + @RequestParam(required = false) visitorType: String?, + @RequestParam(required = false) status: String?, + @RequestParam(required = false) zoneId: UUID? + ): ResponseEntity { + val params = VisitorSearchParams( + from = from, + to = to, + visitorType = visitorType, + status = status, + zoneId = zoneId + ) + + val csv = reportService.exportVisitorsCsv(requestedBy, params) + val filename = buildFilename(from, to) + + return ResponseEntity.ok() + .header( + HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename=\"$filename\"" + ) + .contentType(MediaType("text", "csv")) + .body(csv) + } + + private fun buildFilename( + from: OffsetDateTime?, + to: OffsetDateTime? + ): String { + val fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd") + val fromLabel = from?.let { fmt.format(it) } + ?: fmt.format(LocalDate.now(ZoneOffset.UTC)) + val toLabel = to?.let { fmt.format(it) } + ?: fmt.format(LocalDate.now(ZoneOffset.UTC)) + return "gatelog-visitors-$fromLabel-to-$toLabel.csv" + } +} \ No newline at end of file diff --git a/backend/src/main/kotlin/io/github/devcavin/backend/web/controller/SiteController.kt b/backend/src/main/kotlin/io/github/devcavin/backend/web/controller/SiteController.kt new file mode 100644 index 0000000..0a7d300 --- /dev/null +++ b/backend/src/main/kotlin/io/github/devcavin/backend/web/controller/SiteController.kt @@ -0,0 +1,54 @@ +package io.github.devcavin.backend.web.controller + +import io.github.devcavin.backend.service.SiteService +import io.github.devcavin.backend.web.dto.site.SiteRequest +import io.github.devcavin.backend.web.dto.site.SiteResponse +import jakarta.validation.Valid +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.web.bind.annotation.* +import java.util.* + +@RestController +@RequestMapping("/api/sites") +class SiteController( + private val siteService: SiteService +) { + + @PostMapping + @PreAuthorize("hasRole('SUPER_ADMIN')") + fun create( + @Valid @RequestBody request: SiteRequest + ): ResponseEntity = + ResponseEntity.status(HttpStatus.CREATED).body(siteService.create(request)) + + @GetMapping + @PreAuthorize("hasRole('SUPER_ADMIN')") + fun getAll(): ResponseEntity> = + ResponseEntity.ok(siteService.getAll()) + + @GetMapping("/{id}") + @PreAuthorize("hasAnyRole('SUPER_ADMIN', 'MANAGER')") + fun getById( + @PathVariable id: UUID + ): ResponseEntity = + ResponseEntity.ok(siteService.getById(id)) + + @PutMapping("/{id}") + @PreAuthorize("hasRole('SUPER_ADMIN')") + fun update( + @PathVariable id: UUID, + @Valid @RequestBody request: SiteRequest + ): ResponseEntity = + ResponseEntity.ok(siteService.update(id, request)) + + @DeleteMapping("/{id}") + @PreAuthorize("hasRole('SUPER_ADMIN')") + fun delete( + @PathVariable id: UUID + ): ResponseEntity { + siteService.delete(id) + return ResponseEntity.noContent().build() + } +} \ No newline at end of file diff --git a/backend/src/main/kotlin/io/github/devcavin/backend/web/controller/UserController.kt b/backend/src/main/kotlin/io/github/devcavin/backend/web/controller/UserController.kt index b47fca9..2305a28 100644 --- a/backend/src/main/kotlin/io/github/devcavin/backend/web/controller/UserController.kt +++ b/backend/src/main/kotlin/io/github/devcavin/backend/web/controller/UserController.kt @@ -2,17 +2,24 @@ package io.github.devcavin.backend.web.controller import io.github.devcavin.backend.domain.model.User import io.github.devcavin.backend.service.UserService +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 jakarta.validation.Valid import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.security.access.prepost.PreAuthorize import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController +import java.util.UUID @RestController @RequestMapping("/api/users") @@ -29,4 +36,51 @@ class UserController(private val userService: UserService) { .status(HttpStatus.CREATED) .body(createdUser) } + + @GetMapping + @PreAuthorize("hasAnyRole('SUPER_ADMIN', 'MANAGER')") + fun getAll( + @AuthenticationPrincipal requestedBy: User + ): ResponseEntity> = + ResponseEntity.ok(userService.getAll(requestedBy)) + + @GetMapping("/{id}") + @PreAuthorize("hasAnyRole('SUPER_ADMIN', 'MANAGER')") + fun getById( + @AuthenticationPrincipal requestedBy: User, + @PathVariable id: UUID + ): ResponseEntity = + ResponseEntity.ok(userService.getById(requestedBy, id)) + + @PutMapping("/{id}") + @PreAuthorize("hasAnyRole('SUPER_ADMIN', 'MANAGER')") + fun updateUser( + @AuthenticationPrincipal requestedBy: User, + @PathVariable id: UUID, + @Valid @RequestBody request: UpdateUserRequest + ): ResponseEntity = + ResponseEntity.ok(userService.updateUser(requestedBy, id, request)) + + @PatchMapping("/{id}/deactivate") + @PreAuthorize("hasAnyRole('SUPER_ADMIN', 'MANAGER')") + fun deactivate( + @AuthenticationPrincipal requestedBy: User, + @PathVariable id: UUID + ): ResponseEntity = + ResponseEntity.ok(userService.deactivate(requestedBy, id)) + + @PatchMapping("/{id}/activate") + @PreAuthorize("hasRole('SUPER_ADMIN')") + fun activate( + @AuthenticationPrincipal requestedBy: User, + @PathVariable id: UUID + ): ResponseEntity = + ResponseEntity.ok(userService.activate(requestedBy, id)) + + @PatchMapping("/me/password") + fun changePassword( + @AuthenticationPrincipal requestedBy: User, + @Valid @RequestBody request: ChangePasswordRequest + ): ResponseEntity = + ResponseEntity.ok(userService.changePassword(requestedBy, request)) } \ No newline at end of file diff --git a/backend/src/main/kotlin/io/github/devcavin/backend/web/controller/VisitorController.kt b/backend/src/main/kotlin/io/github/devcavin/backend/web/controller/VisitorController.kt index ee9e7bc..eb546ac 100644 --- a/backend/src/main/kotlin/io/github/devcavin/backend/web/controller/VisitorController.kt +++ b/backend/src/main/kotlin/io/github/devcavin/backend/web/controller/VisitorController.kt @@ -4,6 +4,8 @@ import io.github.devcavin.backend.domain.model.User import io.github.devcavin.backend.service.VisitorService import io.github.devcavin.backend.web.dto.visitor.RegisterVisitorRequest import io.github.devcavin.backend.web.dto.visitor.ReturningVisitorResponse +import io.github.devcavin.backend.web.dto.visitor.UpdateVisitorProfileRequest +import io.github.devcavin.backend.web.dto.visitor.VisitorProfileResponse import io.github.devcavin.backend.web.dto.visitor.VisitorResponse import io.github.devcavin.backend.web.dto.visitor.VisitorSearchParams import jakarta.validation.Valid @@ -12,11 +14,13 @@ import org.springframework.data.domain.Pageable import org.springframework.data.web.PageableDefault import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity +import org.springframework.security.access.prepost.PreAuthorize import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PatchMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam @@ -88,4 +92,13 @@ class VisitorController( return if (result != null) ResponseEntity.ok(result) else ResponseEntity.noContent().build() } + + @PutMapping("/profiles/{profileId}") + @PreAuthorize("hasAnyRole('SUPER_ADMIN', 'MANAGER', 'STAFF')") + fun updateProfile( + @AuthenticationPrincipal requestedBy: User, + @PathVariable profileId: UUID, + @Valid @RequestBody request: UpdateVisitorProfileRequest + ): ResponseEntity = + ResponseEntity.ok(visitorService.updateProfile(requestedBy, profileId, request)) } \ No newline at end of file diff --git a/backend/src/main/kotlin/io/github/devcavin/backend/web/controller/ZoneRepository.kt b/backend/src/main/kotlin/io/github/devcavin/backend/web/controller/ZoneRepository.kt new file mode 100644 index 0000000..18470cb --- /dev/null +++ b/backend/src/main/kotlin/io/github/devcavin/backend/web/controller/ZoneRepository.kt @@ -0,0 +1,60 @@ +package io.github.devcavin.backend.web.controller + +import io.github.devcavin.backend.service.ZoneService +import io.github.devcavin.backend.web.dto.zone.ZoneRequest +import io.github.devcavin.backend.web.dto.zone.ZoneResponse +import jakarta.validation.Valid +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import java.util.UUID + +@RestController +@RequestMapping("/api/sites/{siteId}/zones") +class ZoneController( + private val zoneService: ZoneService +) { + + @PostMapping + @PreAuthorize("hasAnyRole('SUPER_ADMIN', 'MANAGER')") + fun create( + @PathVariable siteId: UUID, + @Valid @RequestBody request: ZoneRequest + ): ResponseEntity = + ResponseEntity.status(HttpStatus.CREATED) + .body(zoneService.create(siteId, request)) + + @GetMapping + @PreAuthorize("hasAnyRole('SUPER_ADMIN', 'MANAGER', 'STAFF')") + fun getAllBySite( + @PathVariable siteId: UUID + ): ResponseEntity> = + ResponseEntity.ok(zoneService.getAllBySite(siteId)) + + @PutMapping("/{zoneId}") + @PreAuthorize("hasAnyRole('SUPER_ADMIN', 'MANAGER')") + fun update( + @PathVariable siteId: UUID, + @PathVariable zoneId: UUID, + @Valid @RequestBody request: ZoneRequest + ): ResponseEntity = + ResponseEntity.ok(zoneService.update(siteId, zoneId, request)) + + @DeleteMapping("/{zoneId}") + @PreAuthorize("hasAnyRole('SUPER_ADMIN', 'MANAGER')") + fun delete( + @PathVariable siteId: UUID, + @PathVariable zoneId: UUID + ): ResponseEntity { + zoneService.delete(siteId, zoneId) + return ResponseEntity.noContent().build() + } +} \ No newline at end of file diff --git a/backend/src/main/kotlin/io/github/devcavin/backend/web/dto/site/SiteDTOs.kt b/backend/src/main/kotlin/io/github/devcavin/backend/web/dto/site/SiteDTOs.kt new file mode 100644 index 0000000..0c534fb --- /dev/null +++ b/backend/src/main/kotlin/io/github/devcavin/backend/web/dto/site/SiteDTOs.kt @@ -0,0 +1,35 @@ +package io.github.devcavin.backend.web.dto.site + +import io.github.devcavin.backend.domain.model.Site +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Size +import java.util.* + +data class SiteRequest( + @field:NotBlank + @field:Size(max = 100) + val name: String, + + @field:NotBlank + @field:Size(max = 255) + val location: String +) + +data class SiteResponse( + val id: UUID, + val name: String, + val location: String +) + +fun SiteRequest.toEntity(): Site { + return Site( + name = name, + location = location + ) +} + +fun Site.toResponse() = SiteResponse( + id = this.id!!, + name = this.name, + location = this.location +) \ No newline at end of file diff --git a/backend/src/main/kotlin/io/github/devcavin/backend/web/dto/user/UserDTOs.kt b/backend/src/main/kotlin/io/github/devcavin/backend/web/dto/user/UserDTOs.kt index c0bc079..0afa570 100644 --- a/backend/src/main/kotlin/io/github/devcavin/backend/web/dto/user/UserDTOs.kt +++ b/backend/src/main/kotlin/io/github/devcavin/backend/web/dto/user/UserDTOs.kt @@ -1,7 +1,6 @@ package io.github.devcavin.backend.web.dto.user import io.github.devcavin.backend.domain.model.User -import jakarta.annotation.Nonnull import jakarta.validation.constraints.Email import jakarta.validation.constraints.NotBlank import jakarta.validation.constraints.Size @@ -26,6 +25,28 @@ data class CreateUserRequest( val siteId: UUID ) +data class UpdateUserRequest( + @field:NotBlank + @field:Size(max = 100) + val name: String, + + @field:NotBlank + @field:Email + val email: String, + + @field:NotBlank + val roleName: String +) + +data class ChangePasswordRequest( + @field:NotBlank + val currentPassword: String, + + @field:NotBlank + @field:Size(min = 8, message = "Password must be at least 8 characters") + val newPassword: String +) + data class UserResponse( val id: UUID, val name: String, diff --git a/backend/src/main/kotlin/io/github/devcavin/backend/web/dto/visitor/VisitorDTOs.kt b/backend/src/main/kotlin/io/github/devcavin/backend/web/dto/visitor/VisitorDTOs.kt index 5519ef9..075c7fa 100644 --- a/backend/src/main/kotlin/io/github/devcavin/backend/web/dto/visitor/VisitorDTOs.kt +++ b/backend/src/main/kotlin/io/github/devcavin/backend/web/dto/visitor/VisitorDTOs.kt @@ -5,7 +5,7 @@ import jakarta.validation.constraints.NotBlank import jakarta.validation.constraints.Pattern import jakarta.validation.constraints.Size import java.time.OffsetDateTime -import java.util.UUID +import java.util.* data class RegisterVisitorRequest( @field:NotBlank @@ -58,6 +58,27 @@ data class VisitorSearchParams( val to: OffsetDateTime? = null ) +data class UpdateVisitorProfileRequest( + @field:NotBlank + @field:Size(max = 100) + val name: String, + + @field:NotBlank + @field:Pattern( + regexp = """^\+?[0-9\s\-]{7,25}$""", + message = "Invalid phone number format" + ) + val phoneNumber: String +) + +data class VisitorProfileResponse( + val id: UUID, + val name: String, + val phoneNumber: String, + val siteId: UUID, + val visitCount: Int +) + fun Visitor.toResponse() = VisitorResponse( id = id!!, name = name, @@ -73,11 +94,3 @@ fun Visitor.toResponse() = VisitorResponse( checkInTime = checkInTime, checkOutTime = checkOutTime ) - -fun Visitor.toReturningResponse() = ReturningVisitorResponse( - name = name, - phone = phone, - visitorType = visitorType, - zoneId = zone?.id, - zoneName = zone?.name -) diff --git a/backend/src/main/kotlin/io/github/devcavin/backend/web/dto/zone/ZoneDTOs.kt b/backend/src/main/kotlin/io/github/devcavin/backend/web/dto/zone/ZoneDTOs.kt new file mode 100644 index 0000000..e742e1c --- /dev/null +++ b/backend/src/main/kotlin/io/github/devcavin/backend/web/dto/zone/ZoneDTOs.kt @@ -0,0 +1,25 @@ +package io.github.devcavin.backend.web.dto.zone + +import io.github.devcavin.backend.domain.model.Zone +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Size +import org.springframework.data.jpa.domain.AbstractPersistable_.id +import java.util.UUID + +data class ZoneRequest( + @field:NotBlank + @field:Size(max = 100) + val name: String +) + +data class ZoneResponse( + val id: UUID, + val name: String, + val siteId: UUID +) + +fun Zone.toResponse() = ZoneResponse( + id = this.id!!, + name = this.name, + siteId = this.site.id!! +) \ No newline at end of file diff --git a/backend/src/main/resources/db/migration/V3__create_users_and_refresh_tokens.sql b/backend/src/main/resources/db/migration/V3__create_users_and_refresh_tokens.sql index 9259572..9c64444 100644 --- a/backend/src/main/resources/db/migration/V3__create_users_and_refresh_tokens.sql +++ b/backend/src/main/resources/db/migration/V3__create_users_and_refresh_tokens.sql @@ -5,7 +5,7 @@ CREATE TABLE users( password_hash VARCHAR(255) NOT NULL, role_id UUID NOT NULL REFERENCES roles(id), site_id UUID NOT NULL REFERENCES sites(id), - created_at timestamptz NOT NULL DEFAULT now(), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), is_active BOOLEAN NOT NULL DEFAULT TRUE ); @@ -16,8 +16,8 @@ CREATE TABLE refresh_tokens ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), token VARCHAR(255) NOT NULL UNIQUE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - created_at timestamptz NOT NULL DEFAULT now(), - expires_at timestamptz NOT NULL + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ NOT NULL ); CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id); \ No newline at end of file diff --git a/backend/src/main/resources/db/migration/V4__create_visitors.sql b/backend/src/main/resources/db/migration/V4__create_visitors.sql index 37438f9..644f908 100644 --- a/backend/src/main/resources/db/migration/V4__create_visitors.sql +++ b/backend/src/main/resources/db/migration/V4__create_visitors.sql @@ -8,8 +8,8 @@ CREATE TABLE visitors ( visit_status_id UUID NOT NULL REFERENCES visit_statuses(id), visitor_type VARCHAR(50) NOT NULL, purpose TEXT NOT NULL DEFAULT 'General Visit', - check_in_time timestamptz NOT NULL DEFAULT now(), - check_out_time timestamptz + check_in_time TIMESTAMPTZ NOT NULL DEFAULT now(), + check_out_time TIMESTAMPTZ ); CREATE INDEX idx_visitors_site_id ON visitors(site_id); diff --git a/backend/src/main/resources/db/migration/V8__create_visitor_profiles.sql b/backend/src/main/resources/db/migration/V8__create_visitor_profiles.sql new file mode 100644 index 0000000..e7acb2e --- /dev/null +++ b/backend/src/main/resources/db/migration/V8__create_visitor_profiles.sql @@ -0,0 +1,16 @@ +CREATE TABLE visitor_profiles( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(100) NOT NULL, + phone_number VARCHAR(25) NOT NULL, + site_id UUID NOT NULL REFERENCES sites(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT uq_visitor_profiles_phone_site UNIQUE(phone_number, site_id) +); + +CREATE INDEX idx_visitor_profiles_phone ON visitor_profiles(phone_number) +CREATE INDEX idx_visitor_profiles_site_id ON visitor_profiles(site_id) + +ALTER TABLE visitors ADD column visitor_profile_id UUID REFERENCES visitor_profiles(id); + +CREATE INDEX idx_visitors_profile_id ON visitors(visitor_profile_id) \ No newline at end of file