Removes redundant DB data fields and adds photo submission endpoint along with MinIO support for image storage

This commit is contained in:
2026-05-14 00:38:44 -05:00
parent 863c824421
commit 5ca7a685dd
15 changed files with 208 additions and 25 deletions

View File

@@ -32,6 +32,8 @@ 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"
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 +48,9 @@ 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")
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

@@ -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>> {
@@ -51,7 +52,8 @@ class TeamController(private val teamService: TeamService) {
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")
@@ -87,7 +89,7 @@ class TeamController(private val teamService: TeamService) {
@PathVariable itemId: ItemId, @PathVariable itemId: ItemId,
authentication: Authentication, authentication: Authentication,
@RequestParam file: MultipartFile): ResponseEntity<PhotoResponse> { @RequestParam file: MultipartFile): ResponseEntity<PhotoResponse> {
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") return ResponseEntity.ok(photoService.submitPhoto(huntId, itemId, authentication.name, file).toResponse())
} }
} }

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,23 @@
package net.halfbinary.scavengerhuntapi.model.converter
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 Photo.toResponse() = PhotoResponse(
id = id,
hunterId = hunterId,
photoUploadDateTime = foundDateTime,
photoStatus = status,
photoStatusChangeDateTime = statusChangeDateTime
)

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,14 @@
package net.halfbinary.scavengerhuntapi.model.response package net.halfbinary.scavengerhuntapi.model.response
import net.halfbinary.scavengerhuntapi.model.HunterId
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 hunterId: HunterId,
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,19 @@
package net.halfbinary.scavengerhuntapi.repository
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.data.jpa.repository.Query
import org.springframework.stereotype.Repository
@Repository
interface PhotoRepository : JpaRepository<PhotoRecord, PhotoId> {
@Query("""
SELECT *
FROM photo p
WHERE
p.
""", nativeQuery = true)
fun findPhotosByItemId(itemId: ItemId): List<PhotoRecord>
}

View File

@@ -0,0 +1,53 @@
package net.halfbinary.scavengerhuntapi.service
import net.coobird.thumbnailator.Thumbnails
import net.halfbinary.scavengerhuntapi.model.HuntId
import net.halfbinary.scavengerhuntapi.model.ItemId
import net.halfbinary.scavengerhuntapi.model.PhotoStatus
import net.halfbinary.scavengerhuntapi.model.converter.toRecord
import net.halfbinary.scavengerhuntapi.model.domain.Photo
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 s3StorageService: S3StorageService
) {
fun submitPhoto(huntId: HuntId, itemId: ItemId, email: String, file: MultipartFile): Photo {
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()
val originalBytes = file.bytes
s3StorageService.upload("$baseName.jpg", originalBytes, MediaType.IMAGE_JPEG_VALUE)
s3StorageService.upload("${baseName}_medium.jpg", resize(originalBytes, 800), MediaType.IMAGE_JPEG_VALUE)
s3StorageService.upload("${baseName}_thumb.jpg", resize(originalBytes, 200), MediaType.IMAGE_JPEG_VALUE)
return photo
}
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

@@ -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=10MB
spring.servlet.multipart.max-request-size=10MB
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