8 Commits

31 changed files with 433 additions and 34 deletions

View File

@@ -32,6 +32,9 @@ dependencies {
val jakartaValidation = "3.1.1" val jakartaValidation = "3.1.1"
val jsonWebToken = "0.13.0" val jsonWebToken = "0.13.0"
val springdocUi = "3.0.3" val springdocUi = "3.0.3"
val awsSdk = "2.26.0"
val thumbnailator = "0.4.20"
val tika = "3.3.0"
implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.springframework.boot:spring-boot-starter-data-jpa")
@@ -46,6 +49,10 @@ dependencies {
implementation("io.jsonwebtoken:jjwt-impl:$jsonWebToken") implementation("io.jsonwebtoken:jjwt-impl:$jsonWebToken")
implementation("io.jsonwebtoken:jjwt-jackson:$jsonWebToken") implementation("io.jsonwebtoken:jjwt-jackson:$jsonWebToken")
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:$springdocUi") implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:$springdocUi")
implementation(platform("software.amazon.awssdk:bom:$awsSdk"))
implementation("software.amazon.awssdk:s3")
implementation("net.coobird:thumbnailator:$thumbnailator")
implementation("org.apache.tika:tika-core:$tika")
developmentOnly("org.springframework.boot:spring-boot-devtools") developmentOnly("org.springframework.boot:spring-boot-devtools")
annotationProcessor("org.springframework.boot:spring-boot-configuration-processor") annotationProcessor("org.springframework.boot:spring-boot-configuration-processor")
testImplementation("org.springframework.boot:spring-boot-starter-actuator-test") testImplementation("org.springframework.boot:spring-boot-starter-actuator-test")

View File

@@ -0,0 +1,26 @@
package net.halfbinary.scavengerhuntapi.config
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider
import software.amazon.awssdk.regions.Region
import software.amazon.awssdk.services.s3.S3Client
import software.amazon.awssdk.services.s3.S3Configuration
import java.net.URI
@Configuration
class S3Config(
@Value("\${minio.endpoint}") private val endpoint: String,
@Value("\${minio.access-key}") private val accessKey: String,
@Value("\${minio.secret-key}") private val secretKey: String
) {
@Bean
fun s3Client(): S3Client = S3Client.builder()
.endpointOverride(URI.create(endpoint))
.credentialsProvider(StaticCredentialsProvider.create(AwsBasicCredentials.create(accessKey, secretKey)))
.region(Region.US_EAST_1)
.serviceConfiguration(S3Configuration.builder().pathStyleAccessEnabled(true).build())
.build()
}

View File

@@ -46,6 +46,6 @@ class HunterController(private val hunterService: HunterService,
@GetMapping("/hunt/{huntId}/team") @GetMapping("/hunt/{huntId}/team")
@Operation(summary = "Gets the Team for the Hunter for the specified Hunt") @Operation(summary = "Gets the Team for the Hunter for the specified Hunt")
fun getHunterHuntTeam(@PathVariable huntId: HuntId, authentication: Authentication): ResponseEntity<TeamResponse> { fun getHunterHuntTeam(@PathVariable huntId: HuntId, authentication: Authentication): ResponseEntity<TeamResponse> {
TODO("Get the Team information for the Hunt for the Hunter (AKA the user)") return ResponseEntity.ok(teamService.getTeamForHunterInHunt(huntId, authentication.name).toResponse())
} }
} }

View File

@@ -1,13 +1,17 @@
package net.halfbinary.scavengerhuntapi.controller package net.halfbinary.scavengerhuntapi.controller
import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.tags.Tag
import jakarta.validation.Valid import jakarta.validation.Valid
import net.halfbinary.scavengerhuntapi.model.HuntId import net.halfbinary.scavengerhuntapi.model.HuntId
import net.halfbinary.scavengerhuntapi.model.ItemId import net.halfbinary.scavengerhuntapi.model.ItemId
import net.halfbinary.scavengerhuntapi.model.converter.toDomain
import net.halfbinary.scavengerhuntapi.model.converter.toResponse
import net.halfbinary.scavengerhuntapi.model.request.ItemRequest import net.halfbinary.scavengerhuntapi.model.request.ItemRequest
import net.halfbinary.scavengerhuntapi.model.response.ItemResponse import net.halfbinary.scavengerhuntapi.model.response.ItemResponse
import net.halfbinary.scavengerhuntapi.service.HuntService import net.halfbinary.scavengerhuntapi.service.HuntService
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.PostMapping
@@ -21,7 +25,7 @@ class ItemController(private val huntService: HuntService) {
@GetMapping @GetMapping
fun getItemsForHunt(@PathVariable huntId: HuntId): ResponseEntity<List<ItemResponse>> { fun getItemsForHunt(@PathVariable huntId: HuntId): ResponseEntity<List<ItemResponse>> {
TODO("List the Items related to the specified Hunt") return ResponseEntity.ok(huntService.getItemsForHunt(huntId).map { it.toResponse() })
} }
@GetMapping("/{itemId}") @GetMapping("/{itemId}")
@@ -29,10 +33,12 @@ class ItemController(private val huntService: HuntService) {
TODO("Get detailed information about the specified Item for the specified Hunt") TODO("Get detailed information about the specified Item for the specified Hunt")
} }
@PreAuthorize("hasRole('ADMIN')")
@Tag(name = "Admin")
@PostMapping @PostMapping
@Operation(summary = "Adds new Item to specified Hunt") @Operation(summary = "Adds new Item to specified Hunt")
fun addItemToHunt(@PathVariable huntId: HuntId, @Valid @RequestBody body: ItemRequest) { fun addItemToHunt(@PathVariable huntId: HuntId, @Valid @RequestBody body: ItemRequest): ResponseEntity<ItemResponse> {
TODO("Add a new item to the specified Hunt") return ResponseEntity.ok(huntService.addItemToHunt(huntId, body.toDomain()).toResponse())
} }
} }

View File

@@ -11,6 +11,7 @@ import net.halfbinary.scavengerhuntapi.model.request.TeamRequest
import net.halfbinary.scavengerhuntapi.model.response.PhotoResponse import net.halfbinary.scavengerhuntapi.model.response.PhotoResponse
import net.halfbinary.scavengerhuntapi.model.response.TeamItemResponse import net.halfbinary.scavengerhuntapi.model.response.TeamItemResponse
import net.halfbinary.scavengerhuntapi.model.response.TeamResponse import net.halfbinary.scavengerhuntapi.model.response.TeamResponse
import net.halfbinary.scavengerhuntapi.service.PhotoService
import net.halfbinary.scavengerhuntapi.service.TeamService import net.halfbinary.scavengerhuntapi.service.TeamService
import org.springframework.core.io.InputStreamSource import org.springframework.core.io.InputStreamSource
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
@@ -26,7 +27,7 @@ import org.springframework.web.multipart.MultipartFile
@RestController @RestController
@RequestMapping("hunt/{huntId}/team") @RequestMapping("hunt/{huntId}/team")
class TeamController(private val teamService: TeamService) { class TeamController(private val teamService: TeamService, private val photoService: PhotoService) {
@GetMapping @GetMapping
@Operation(summary = "List all teams for the specified hunt") @Operation(summary = "List all teams for the specified hunt")
fun listHuntTeams(@PathVariable huntId: HuntId): ResponseEntity<List<TeamResponse>> { fun listHuntTeams(@PathVariable huntId: HuntId): ResponseEntity<List<TeamResponse>> {
@@ -41,18 +42,22 @@ class TeamController(private val teamService: TeamService) {
} }
@GetMapping("/{teamId}") @GetMapping("/{teamId}")
@Operation(summary = "Get team info for the specified hunt")
fun getTeam(@PathVariable huntId: HuntId, @PathVariable teamId: TeamId): ResponseEntity<TeamResponse> { fun getTeam(@PathVariable huntId: HuntId, @PathVariable teamId: TeamId): ResponseEntity<TeamResponse> {
return ResponseEntity.ok(teamService.getTeamFromHunt(huntId, teamId).toResponse()) return ResponseEntity.ok(teamService.getTeamFromHunt(huntId, teamId).toResponse())
} }
@GetMapping("/{teamId}/item/{itemId}") @GetMapping("/{teamId}/item/{itemId}")
@Operation(summary = "Get found/not found status and photo information about the Item for the specified Team, Hunt, and Item")
fun getItemForTeam(@PathVariable huntId: HuntId, fun getItemForTeam(@PathVariable huntId: HuntId,
@PathVariable teamId: TeamId, @PathVariable teamId: TeamId,
@PathVariable itemId: ItemId): ResponseEntity<TeamItemResponse> { @PathVariable itemId: ItemId): ResponseEntity<TeamItemResponse> {
TODO("Get found/not found status and photo information about the Item for the specified Team, Hunt, and Item") TODO("Get found/not found status about the Item for the specified Team, Hunt, and Item")
} }
@GetMapping("/{teamId}/item/{itemId}/photo") @GetMapping("/{teamId}/item/{itemId}/photo")
@Operation(summary = "Get list of photo information for the specified Team, Hunt, and Item")
fun getItemPhotos(@PathVariable huntId: HuntId, fun getItemPhotos(@PathVariable huntId: HuntId,
@PathVariable teamId: TeamId, @PathVariable teamId: TeamId,
@PathVariable itemId: ItemId): ResponseEntity<List<PhotoResponse>> { @PathVariable itemId: ItemId): ResponseEntity<List<PhotoResponse>> {
@@ -60,14 +65,17 @@ class TeamController(private val teamService: TeamService) {
} }
@GetMapping("/{teamId}/item/{itemId}/photo/{photoId}") @GetMapping("/{teamId}/item/{itemId}/photo/{photoId}")
@Operation(summary = "Get photo information for the specified Team, Hunt, Item, and Photo")
fun getPhotoInfo(@PathVariable huntId: HuntId, fun getPhotoInfo(@PathVariable huntId: HuntId,
@PathVariable teamId: TeamId, @PathVariable teamId: TeamId,
@PathVariable itemId: ItemId, @PathVariable itemId: ItemId,
@PathVariable photoId: PhotoId): ResponseEntity<PhotoResponse> { @PathVariable photoId: PhotoId,
TODO("Get photo information for the specified Team, Hunt, Item, and Photo") authentication: Authentication): ResponseEntity<PhotoResponse> {
return ResponseEntity.ok(photoService.getPhotoInfo(huntId, teamId, itemId, photoId, authentication.name))
} }
@GetMapping("/{teamId}/item/{itemId}/photo/{photoId}/file") @GetMapping("/{teamId}/item/{itemId}/photo/{photoId}/file")
@Operation(summary = "Get the binary image information for the specified Team, Hunt, Item, and Photo")
fun getPhoto(@PathVariable huntId: HuntId, fun getPhoto(@PathVariable huntId: HuntId,
@PathVariable teamId: TeamId, @PathVariable teamId: TeamId,
@PathVariable itemId: ItemId, @PathVariable itemId: ItemId,
@@ -76,12 +84,13 @@ class TeamController(private val teamService: TeamService) {
} }
@PostMapping("/{teamId}/item/{itemId}/photo") @PostMapping("/{teamId}/item/{itemId}/photo")
@Operation(summary = "Save photo information and store the binary file")
fun submitPhoto(@PathVariable huntId: HuntId, fun submitPhoto(@PathVariable huntId: HuntId,
@PathVariable teamId: TeamId, @PathVariable teamId: TeamId,
@PathVariable itemId: ItemId, @PathVariable itemId: ItemId,
authentication: Authentication, authentication: Authentication,
@RequestParam file: MultipartFile): ResponseEntity<PhotoResponse> { @RequestParam file: MultipartFile) {
TODO("Save photo information in the Photo table so that it relates to the specified Team, Hunt, Hunter, and Item, and store the binary file") photoService.submitPhoto(huntId, itemId, authentication.name, file)
} }
} }

View File

@@ -1,5 +1,7 @@
package net.halfbinary.scavengerhuntapi.error package net.halfbinary.scavengerhuntapi.error
import net.halfbinary.scavengerhuntapi.error.exception.BadFileException
import net.halfbinary.scavengerhuntapi.error.exception.ForbiddenException
import net.halfbinary.scavengerhuntapi.error.exception.InvalidEmailException import net.halfbinary.scavengerhuntapi.error.exception.InvalidEmailException
import net.halfbinary.scavengerhuntapi.error.exception.LoginFailedException import net.halfbinary.scavengerhuntapi.error.exception.LoginFailedException
import net.halfbinary.scavengerhuntapi.error.exception.NotFoundException import net.halfbinary.scavengerhuntapi.error.exception.NotFoundException
@@ -12,6 +14,8 @@ import org.springframework.web.bind.MethodArgumentNotValidException
import org.springframework.web.bind.annotation.ExceptionHandler import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestControllerAdvice import org.springframework.web.bind.annotation.RestControllerAdvice
import org.springframework.web.multipart.MaxUploadSizeExceededException
import java.net.SocketTimeoutException
@RestControllerAdvice @RestControllerAdvice
@@ -43,6 +47,12 @@ class ExceptionHandler {
return e.message return e.message
} }
@ExceptionHandler(ForbiddenException::class)
@ResponseStatus(HttpStatus.FORBIDDEN)
fun forbiddenException(e: ForbiddenException): String? {
return e.message
}
@ExceptionHandler(HttpMessageNotReadableException::class) @ExceptionHandler(HttpMessageNotReadableException::class)
@ResponseStatus(HttpStatus.BAD_REQUEST) @ResponseStatus(HttpStatus.BAD_REQUEST)
fun httpMessageNotReadableException(e: HttpMessageNotReadableException): Map<String, String?> { fun httpMessageNotReadableException(e: HttpMessageNotReadableException): Map<String, String?> {
@@ -68,6 +78,24 @@ class ExceptionHandler {
} }
} }
@ExceptionHandler(BadFileException::class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
fun badFileException(e: BadFileException): String? {
return e.message
}
@ExceptionHandler(MaxUploadSizeExceededException::class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
fun maxUploadSizeExceededException(e: MaxUploadSizeExceededException): String? {
return e.message
}
@ExceptionHandler(SocketTimeoutException::class)
@ResponseStatus(HttpStatus.SERVICE_UNAVAILABLE)
fun socketTimeoutException(): String {
return "Unable to connect. Try again later."
}
private fun simpleMap(key: String, value: String?): Map<String, String?> { private fun simpleMap(key: String, value: String?): Map<String, String?> {
return mapOf(Pair(key, value)) return mapOf(Pair(key, value))
} }

View File

@@ -0,0 +1,3 @@
package net.halfbinary.scavengerhuntapi.error.exception
class BadFileException(override val message: String): RuntimeException(message)

View File

@@ -0,0 +1,3 @@
package net.halfbinary.scavengerhuntapi.error.exception
class ForbiddenException(override val message: String): RuntimeException(message)

View File

@@ -0,0 +1,8 @@
package net.halfbinary.scavengerhuntapi.model
enum class PhotoStatus {
SUBMITTED,
APPROVED,
REJECTED,
REMOVED
}

View File

@@ -2,7 +2,6 @@ package net.halfbinary.scavengerhuntapi.model
import java.util.* import java.util.*
typealias FoundId = UUID
typealias HuntId = UUID typealias HuntId = UUID
typealias HunterId = UUID typealias HunterId = UUID
typealias ItemId = UUID typealias ItemId = UUID

View File

@@ -0,0 +1,8 @@
package net.halfbinary.scavengerhuntapi.model.converter
import net.halfbinary.scavengerhuntapi.model.domain.HuntItem
import net.halfbinary.scavengerhuntapi.model.record.HuntItemRecord
fun HuntItem.toRecord() = HuntItemRecord(id = id, huntId = huntId, itemId = itemId)
fun HuntItemRecord.toDomain() = HuntItem(id = id, huntId = huntId, itemId = itemId)

View File

@@ -0,0 +1,14 @@
package net.halfbinary.scavengerhuntapi.model.converter
import net.halfbinary.scavengerhuntapi.model.domain.Item
import net.halfbinary.scavengerhuntapi.model.record.ItemRecord
import net.halfbinary.scavengerhuntapi.model.request.ItemRequest
import net.halfbinary.scavengerhuntapi.model.response.ItemResponse
fun ItemRequest.toDomain() = Item(name = name, points = points)
fun Item.toRecord() = ItemRecord(id = id, name = name, points = points)
fun ItemRecord.toDomain() = Item(id = id, name = name, points = points)
fun Item.toResponse() = ItemResponse(id = id, name = name, points = points)

View File

@@ -0,0 +1,34 @@
package net.halfbinary.scavengerhuntapi.model.converter
import net.halfbinary.scavengerhuntapi.model.domain.Hunter
import net.halfbinary.scavengerhuntapi.model.domain.Photo
import net.halfbinary.scavengerhuntapi.model.record.PhotoRecord
import net.halfbinary.scavengerhuntapi.model.response.PhotoResponse
fun Photo.toRecord() = PhotoRecord(
id = id,
itemId = itemId,
huntId = huntId,
hunterId = hunterId,
foundDateTime = foundDateTime,
status = status,
statusChangeDateTime = statusChangeDateTime
)
fun PhotoRecord.toDomain() = Photo(
id = id,
itemId = itemId,
huntId = huntId,
hunterId = hunterId,
foundDateTime = foundDateTime,
status = status,
statusChangeDateTime = statusChangeDateTime
)
fun Photo.toResponse(hunter: Hunter) = PhotoResponse(
id = id,
hunterName = hunter.name,
photoUploadDateTime = foundDateTime,
photoStatus = status,
photoStatusChangeDateTime = statusChangeDateTime
)

View File

@@ -0,0 +1,11 @@
package net.halfbinary.scavengerhuntapi.model.domain
import net.halfbinary.scavengerhuntapi.model.HuntId
import net.halfbinary.scavengerhuntapi.model.ItemId
import java.util.*
data class HuntItem(
val id: UUID = UUID.randomUUID(),
val huntId: HuntId,
val itemId: ItemId
)

View File

@@ -0,0 +1,10 @@
package net.halfbinary.scavengerhuntapi.model.domain
import net.halfbinary.scavengerhuntapi.model.ItemId
import java.util.*
data class Item(
val id: ItemId = UUID.randomUUID(),
val name: String,
val points: Int
)

View File

@@ -0,0 +1,19 @@
package net.halfbinary.scavengerhuntapi.model.domain
import net.halfbinary.scavengerhuntapi.model.HuntId
import net.halfbinary.scavengerhuntapi.model.HunterId
import net.halfbinary.scavengerhuntapi.model.ItemId
import net.halfbinary.scavengerhuntapi.model.PhotoId
import net.halfbinary.scavengerhuntapi.model.PhotoStatus
import java.time.LocalDateTime
import java.util.UUID
data class Photo(
val id: PhotoId = UUID.randomUUID(),
val itemId: ItemId,
val huntId: HuntId,
val hunterId: HunterId,
val foundDateTime: LocalDateTime,
val status: PhotoStatus,
val statusChangeDateTime: LocalDateTime
)

View File

@@ -3,25 +3,25 @@ package net.halfbinary.scavengerhuntapi.model.record
import jakarta.persistence.Entity import jakarta.persistence.Entity
import jakarta.persistence.Id import jakarta.persistence.Id
import jakarta.persistence.Table import jakarta.persistence.Table
import net.halfbinary.scavengerhuntapi.model.FoundId
import net.halfbinary.scavengerhuntapi.model.FoundStatus
import net.halfbinary.scavengerhuntapi.model.HuntId import net.halfbinary.scavengerhuntapi.model.HuntId
import net.halfbinary.scavengerhuntapi.model.HunterId import net.halfbinary.scavengerhuntapi.model.HunterId
import net.halfbinary.scavengerhuntapi.model.ItemId import net.halfbinary.scavengerhuntapi.model.ItemId
import net.halfbinary.scavengerhuntapi.model.PhotoId
import net.halfbinary.scavengerhuntapi.model.PhotoStatus
import java.time.LocalDateTime import java.time.LocalDateTime
/** /**
* Represents a found Item for a Hunt by a Hunter * Represents a found Item for a Hunt by a Hunter
*/ */
@Entity @Entity
@Table(name = "found") @Table(name = "photo")
data class FoundRecord( data class PhotoRecord(
@Id @Id
val id: FoundId, val id: PhotoId,
val itemId: ItemId, val itemId: ItemId,
val huntId: HuntId, val huntId: HuntId,
val hunterId: HunterId, val hunterId: HunterId,
val foundDateTime: LocalDateTime, val foundDateTime: LocalDateTime,
val imageName: String, val status: PhotoStatus,
val status: FoundStatus val statusChangeDateTime: LocalDateTime,
) )

View File

@@ -1,10 +1,13 @@
package net.halfbinary.scavengerhuntapi.model.response package net.halfbinary.scavengerhuntapi.model.response
import net.halfbinary.scavengerhuntapi.model.PhotoId import net.halfbinary.scavengerhuntapi.model.PhotoId
import net.halfbinary.scavengerhuntapi.model.PhotoStatus
import java.time.LocalDateTime import java.time.LocalDateTime
data class PhotoResponse( data class PhotoResponse(
val id: PhotoId, val id: PhotoId,
val hunterName: String, val hunterName: String,
val photoUploadDateTime: LocalDateTime val photoUploadDateTime: LocalDateTime,
val photoStatus: PhotoStatus,
val photoStatusChangeDateTime: LocalDateTime,
) )

View File

@@ -2,10 +2,11 @@ package net.halfbinary.scavengerhuntapi.model.response
import net.halfbinary.scavengerhuntapi.model.FoundStatus import net.halfbinary.scavengerhuntapi.model.FoundStatus
import net.halfbinary.scavengerhuntapi.model.ItemId import net.halfbinary.scavengerhuntapi.model.ItemId
import java.time.LocalDateTime
data class TeamItemResponse( data class TeamItemResponse(
val id: ItemId, val id: ItemId,
val itemName: String, val hunterName: String?,
val hunterName: String, val itemFoundStatus: FoundStatus,
val itemFoundStatus: FoundStatus val itemStatusChangeDateTime: LocalDateTime,
) )

View File

@@ -1,9 +0,0 @@
package net.halfbinary.scavengerhuntapi.repository
import net.halfbinary.scavengerhuntapi.model.FoundId
import net.halfbinary.scavengerhuntapi.model.record.FoundRecord
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository
interface FoundRepository : JpaRepository<FoundRecord, FoundId>

View File

@@ -0,0 +1,9 @@
package net.halfbinary.scavengerhuntapi.repository
import net.halfbinary.scavengerhuntapi.model.record.HuntItemRecord
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
import java.util.*
@Repository
interface HuntItemRepository : JpaRepository<HuntItemRecord, UUID>

View File

@@ -1,9 +1,12 @@
package net.halfbinary.scavengerhuntapi.repository package net.halfbinary.scavengerhuntapi.repository
import net.halfbinary.scavengerhuntapi.model.HunterId
import net.halfbinary.scavengerhuntapi.model.record.HunterTeamRecord import net.halfbinary.scavengerhuntapi.model.record.HunterTeamRecord
import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository import org.springframework.stereotype.Repository
import java.util.* import java.util.*
@Repository @Repository
interface HunterTeamRepository : JpaRepository<HunterTeamRecord, UUID> interface HunterTeamRepository : JpaRepository<HunterTeamRecord, UUID> {
fun findByHunterId(hunterId: HunterId): List<HunterTeamRecord>
}

View File

@@ -1,9 +1,19 @@
package net.halfbinary.scavengerhuntapi.repository package net.halfbinary.scavengerhuntapi.repository
import net.halfbinary.scavengerhuntapi.model.HuntId
import net.halfbinary.scavengerhuntapi.model.ItemId import net.halfbinary.scavengerhuntapi.model.ItemId
import net.halfbinary.scavengerhuntapi.model.record.ItemRecord import net.halfbinary.scavengerhuntapi.model.record.ItemRecord
import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import org.springframework.stereotype.Repository import org.springframework.stereotype.Repository
@Repository @Repository
interface ItemRepository : JpaRepository<ItemRecord, ItemId> interface ItemRepository : JpaRepository<ItemRecord, ItemId> {
@Query("""
SELECT i.*
FROM item i
INNER JOIN hunt_item hi ON i.id = hi.item_id
WHERE hi.hunt_id = :huntId
""", nativeQuery = true)
fun findAllByHuntId(huntId: HuntId): List<ItemRecord>
}

View File

@@ -0,0 +1,13 @@
package net.halfbinary.scavengerhuntapi.repository
import net.halfbinary.scavengerhuntapi.model.HuntId
import net.halfbinary.scavengerhuntapi.model.ItemId
import net.halfbinary.scavengerhuntapi.model.PhotoId
import net.halfbinary.scavengerhuntapi.model.record.PhotoRecord
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository
interface PhotoRepository : JpaRepository<PhotoRecord, PhotoId> {
fun findByItemId(itemId: ItemId): List<PhotoRecord>
fun findByIdAndItemIdAndHuntId(id: PhotoId, itemId: ItemId, huntId: HuntId): PhotoRecord?
}

View File

@@ -0,0 +1,22 @@
package net.halfbinary.scavengerhuntapi.service
import org.apache.tika.Tika
import org.apache.tika.config.TikaConfig
import org.springframework.stereotype.Service
@Service
class FileProbeService {
private val tika = Tika()
fun getFileType(fileBytes: ByteArray): String {
return tika.detect(fileBytes)
}
fun getFileExtension(fileType: String): String {
return TikaConfig.getDefaultConfig().mimeRepository.forName(fileType).extension
}
fun isImageType(fileType: String): Boolean {
return fileType.startsWith("image")
}
}

View File

@@ -6,14 +6,22 @@ import net.halfbinary.scavengerhuntapi.model.HunterId
import net.halfbinary.scavengerhuntapi.model.converter.toDomain import net.halfbinary.scavengerhuntapi.model.converter.toDomain
import net.halfbinary.scavengerhuntapi.model.converter.toRecord import net.halfbinary.scavengerhuntapi.model.converter.toRecord
import net.halfbinary.scavengerhuntapi.model.domain.Hunt import net.halfbinary.scavengerhuntapi.model.domain.Hunt
import net.halfbinary.scavengerhuntapi.model.domain.HuntItem
import net.halfbinary.scavengerhuntapi.model.domain.Item
import net.halfbinary.scavengerhuntapi.model.request.HuntStatus import net.halfbinary.scavengerhuntapi.model.request.HuntStatus
import net.halfbinary.scavengerhuntapi.repository.HuntItemRepository
import net.halfbinary.scavengerhuntapi.repository.HuntRepository import net.halfbinary.scavengerhuntapi.repository.HuntRepository
import net.halfbinary.scavengerhuntapi.repository.ItemRepository
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import java.time.LocalDateTime import java.time.LocalDateTime
@Service @Service
class HuntService(private val huntRepository: HuntRepository) { class HuntService(
private val huntRepository: HuntRepository,
private val itemRepository: ItemRepository,
private val huntItemRepository: HuntItemRepository
) {
fun getHunt(huntId: HuntId): Hunt { fun getHunt(huntId: HuntId): Hunt {
return huntRepository.findByIdOrNull(huntId)?.toDomain() ?: throw NotFoundException("No hunt with id $huntId found") return huntRepository.findByIdOrNull(huntId)?.toDomain() ?: throw NotFoundException("No hunt with id $huntId found")
} }
@@ -55,4 +63,16 @@ class HuntService(private val huntRepository: HuntRepository) {
fun createHunt(hunt: Hunt): Hunt { fun createHunt(hunt: Hunt): Hunt {
return huntRepository.save(hunt.toRecord()).toDomain() return huntRepository.save(hunt.toRecord()).toDomain()
} }
fun getItemsForHunt(huntId: HuntId): List<Item> {
huntRepository.findByIdOrNull(huntId) ?: throw NotFoundException("No hunt with id $huntId found")
return itemRepository.findAllByHuntId(huntId).map { it.toDomain() }
}
fun addItemToHunt(huntId: HuntId, item: Item): Item {
huntRepository.findByIdOrNull(huntId) ?: throw NotFoundException("No hunt with id $huntId found")
val savedItem = itemRepository.save(item.toRecord()).toDomain()
huntItemRepository.save(HuntItem(huntId = huntId, itemId = savedItem.id).toRecord())
return savedItem
}
} }

View File

@@ -1,6 +1,7 @@
package net.halfbinary.scavengerhuntapi.service package net.halfbinary.scavengerhuntapi.service
import net.halfbinary.scavengerhuntapi.error.exception.NotFoundException import net.halfbinary.scavengerhuntapi.error.exception.NotFoundException
import net.halfbinary.scavengerhuntapi.model.HunterId
import net.halfbinary.scavengerhuntapi.model.converter.toDomain import net.halfbinary.scavengerhuntapi.model.converter.toDomain
import net.halfbinary.scavengerhuntapi.model.domain.Hunter import net.halfbinary.scavengerhuntapi.model.domain.Hunter
import net.halfbinary.scavengerhuntapi.repository.HunterRepository import net.halfbinary.scavengerhuntapi.repository.HunterRepository
@@ -12,4 +13,8 @@ class HunterService(private val hunterRepository: HunterRepository) {
return hunterRepository.findByEmail(email)?.toDomain() return hunterRepository.findByEmail(email)?.toDomain()
?: throw NotFoundException("No hunter with email $email found") ?: throw NotFoundException("No hunter with email $email found")
} }
fun getHunterById(hunterId: HunterId): Hunter {
return hunterRepository.findById(hunterId).orElseThrow { NotFoundException("No hunter with id $hunterId found") }.toDomain()
}
} }

View File

@@ -0,0 +1,96 @@
package net.halfbinary.scavengerhuntapi.service
import net.coobird.thumbnailator.Thumbnails
import net.coobird.thumbnailator.tasks.UnsupportedFormatException
import net.halfbinary.scavengerhuntapi.error.exception.BadFileException
import net.halfbinary.scavengerhuntapi.error.exception.ForbiddenException
import net.halfbinary.scavengerhuntapi.error.exception.NotFoundException
import net.halfbinary.scavengerhuntapi.model.HuntId
import net.halfbinary.scavengerhuntapi.model.ItemId
import net.halfbinary.scavengerhuntapi.model.PhotoId
import net.halfbinary.scavengerhuntapi.model.PhotoStatus
import net.halfbinary.scavengerhuntapi.model.TeamId
import net.halfbinary.scavengerhuntapi.model.converter.toDomain
import net.halfbinary.scavengerhuntapi.model.converter.toRecord
import net.halfbinary.scavengerhuntapi.model.converter.toResponse
import net.halfbinary.scavengerhuntapi.model.domain.Photo
import net.halfbinary.scavengerhuntapi.model.response.PhotoResponse
import net.halfbinary.scavengerhuntapi.repository.PhotoRepository
import org.springframework.http.MediaType
import org.springframework.stereotype.Service
import org.springframework.web.multipart.MultipartFile
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.time.LocalDateTime
@Service
class PhotoService(
private val photoRepository: PhotoRepository,
private val hunterService: HunterService,
private val teamService: TeamService,
private val s3StorageService: S3StorageService,
private val fileProbeService: FileProbeService
) {
fun submitPhoto(huntId: HuntId, itemId: ItemId, email: String, file: MultipartFile) {
val originalBytes = file.bytes
val fileType = fileProbeService.getFileType(originalBytes)
if(!fileProbeService.isImageType(fileType)) throw BadFileException("Not an image")
val originalAsJpeg = try {
toJpeg(file.bytes)
} catch (_: UnsupportedFormatException) {
throw BadFileException("Image type is not supported")
}
val hunter = hunterService.getHunterByEmail(email)
val now = LocalDateTime.now()
val photo = Photo(
itemId = itemId,
huntId = huntId,
hunterId = hunter.id,
foundDateTime = now,
status = PhotoStatus.SUBMITTED,
statusChangeDateTime = now
)
val savedRecord = photoRepository.save(photo.toRecord())
val baseName = savedRecord.id.toString()
s3StorageService.upload("$baseName${fileProbeService.getFileExtension(fileType)}", originalBytes, fileType)
s3StorageService.upload("${baseName}_large.jpg", originalAsJpeg, MediaType.IMAGE_JPEG_VALUE)
s3StorageService.upload("${baseName}_medium.jpg", resize(originalBytes, 800), MediaType.IMAGE_JPEG_VALUE)
s3StorageService.upload("${baseName}_small.jpg", resize(originalBytes, 200), MediaType.IMAGE_JPEG_VALUE)
}
fun getPhotoInfo(huntId: HuntId, teamId: TeamId, itemId: ItemId, photoId: PhotoId, email: String): PhotoResponse {
val requestingHunter = hunterService.getHunterByEmail(email)
val photoRecord = photoRepository.findByIdAndItemIdAndHuntId(photoId, itemId, huntId)
?: throw NotFoundException("Photo not found")
if (!requestingHunter.isAdmin) {
val team = teamService.getTeamForHunterInHunt(huntId, email)
if (team.id != teamId) throw ForbiddenException("Access denied")
}
val submitter = hunterService.getHunterById(photoRecord.hunterId)
return photoRecord.toDomain().toResponse(submitter)
}
private fun toJpeg(bytes: ByteArray): ByteArray {
val output = ByteArrayOutputStream()
Thumbnails.of(ByteArrayInputStream(bytes))
.scale(1.0)
.outputFormat("jpg")
.toOutputStream(output)
return output.toByteArray()
}
private fun resize(bytes: ByteArray, width: Int): ByteArray {
val output = ByteArrayOutputStream()
Thumbnails.of(ByteArrayInputStream(bytes))
.width(width)
.outputFormat("jpg")
.toOutputStream(output)
return output.toByteArray()
}
}

View File

@@ -0,0 +1,25 @@
package net.halfbinary.scavengerhuntapi.service
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import software.amazon.awssdk.core.sync.RequestBody
import software.amazon.awssdk.services.s3.S3Client
import software.amazon.awssdk.services.s3.model.PutObjectRequest
@Service
class S3StorageService(
private val s3Client: S3Client,
@Value("\${minio.bucket}") private val bucket: String
) {
fun upload(key: String, bytes: ByteArray, contentType: String) {
s3Client.putObject(
PutObjectRequest.builder()
.bucket(bucket)
.key(key)
.contentType(contentType)
.contentLength(bytes.size.toLong())
.build(),
RequestBody.fromBytes(bytes)
)
}
}

View File

@@ -41,6 +41,14 @@ class TeamService(
.elementAt(0) .elementAt(0)
} }
fun getTeamForHunterInHunt(huntId: HuntId, email: String): Team {
val hunter = hunterRepository.findByEmail(email) ?: throw NotFoundException("No hunter with email $email found")
val hunterTeamIds = hunterTeamRepository.findByHunterId(hunter.id).map { it.teamId }.toSet()
return getTeamsForHunt(huntId)
.firstOrNull { it.id in hunterTeamIds }
?: throw NotFoundException("No team found for hunter $email in hunt $huntId")
}
fun joinTeam(teamId: TeamId, email: String) { fun joinTeam(teamId: TeamId, email: String) {
val hunter = hunterRepository.findByEmail(email) ?: throw NotFoundException("No hunter with email $email found") val hunter = hunterRepository.findByEmail(email) ?: throw NotFoundException("No hunter with email $email found")
hunterTeamRepository.save(HunterTeamRecord(UUID.randomUUID(), hunter.id, teamId)) hunterTeamRepository.save(HunterTeamRecord(UUID.randomUUID(), hunter.id, teamId))

View File

@@ -12,6 +12,14 @@ spring.datasource.password=${DB_PASSWORD}
jwt.secret=${JWT_SECRET} jwt.secret=${JWT_SECRET}
jwt.expiration=300000 jwt.expiration=300000
minio.endpoint=${MINIO_ENDPOINT}
minio.access-key=${MINIO_ACCESS_KEY}
minio.secret-key=${MINIO_SECRET_KEY}
minio.bucket=${MINIO_BUCKET}
spring.servlet.multipart.max-file-size=25MB
spring.servlet.multipart.max-request-size=25MB
springdoc.api-docs.enabled=true springdoc.api-docs.enabled=true
springdoc.api-docs.path=/docs/api-docs springdoc.api-docs.path=/docs/api-docs
springdoc.swagger-ui.enabled=true springdoc.swagger-ui.enabled=true