From 5ca7a685dd3d3823fbbcefd28ec435f668b1aa6c Mon Sep 17 00:00:00 2001 From: aarbit Date: Thu, 14 May 2026 00:38:44 -0500 Subject: [PATCH] Removes redundant DB data fields and adds photo submission endpoint along with MinIO support for image storage --- build.gradle.kts | 5 ++ .../scavengerhuntapi/config/S3Config.kt | 26 +++++++++ .../controller/TeamController.kt | 8 +-- .../scavengerhuntapi/model/PhotoStatus.kt | 8 +++ .../scavengerhuntapi/model/TypeAlias.kt | 1 - .../model/converter/PhotoConverter.kt | 23 ++++++++ .../scavengerhuntapi/model/domain/Photo.kt | 19 +++++++ .../record/{FoundRecord.kt => PhotoRecord.kt} | 14 ++--- .../model/response/PhotoResponse.kt | 8 ++- .../model/response/TeamItemResponse.kt | 7 +-- .../repository/FoundRepository.kt | 9 ---- .../repository/PhotoRepository.kt | 19 +++++++ .../scavengerhuntapi/service/PhotoService.kt | 53 +++++++++++++++++++ .../service/S3StorageService.kt | 25 +++++++++ src/main/resources/application.properties | 8 +++ 15 files changed, 208 insertions(+), 25 deletions(-) create mode 100644 src/main/kotlin/net/halfbinary/scavengerhuntapi/config/S3Config.kt create mode 100644 src/main/kotlin/net/halfbinary/scavengerhuntapi/model/PhotoStatus.kt create mode 100644 src/main/kotlin/net/halfbinary/scavengerhuntapi/model/converter/PhotoConverter.kt create mode 100644 src/main/kotlin/net/halfbinary/scavengerhuntapi/model/domain/Photo.kt rename src/main/kotlin/net/halfbinary/scavengerhuntapi/model/record/{FoundRecord.kt => PhotoRecord.kt} (67%) delete mode 100644 src/main/kotlin/net/halfbinary/scavengerhuntapi/repository/FoundRepository.kt create mode 100644 src/main/kotlin/net/halfbinary/scavengerhuntapi/repository/PhotoRepository.kt create mode 100644 src/main/kotlin/net/halfbinary/scavengerhuntapi/service/PhotoService.kt create mode 100644 src/main/kotlin/net/halfbinary/scavengerhuntapi/service/S3StorageService.kt diff --git a/build.gradle.kts b/build.gradle.kts index 6db3bbd..079485c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -32,6 +32,8 @@ dependencies { val jakartaValidation = "3.1.1" val jsonWebToken = "0.13.0" 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-web") implementation("org.springframework.boot:spring-boot-starter-data-jpa") @@ -46,6 +48,9 @@ dependencies { implementation("io.jsonwebtoken:jjwt-impl:$jsonWebToken") implementation("io.jsonwebtoken:jjwt-jackson:$jsonWebToken") 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") annotationProcessor("org.springframework.boot:spring-boot-configuration-processor") testImplementation("org.springframework.boot:spring-boot-starter-actuator-test") diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/config/S3Config.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/config/S3Config.kt new file mode 100644 index 0000000..7a0b912 --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/config/S3Config.kt @@ -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() +} diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/controller/TeamController.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/controller/TeamController.kt index 1b26eeb..16f9950 100644 --- a/src/main/kotlin/net/halfbinary/scavengerhuntapi/controller/TeamController.kt +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/controller/TeamController.kt @@ -11,6 +11,7 @@ import net.halfbinary.scavengerhuntapi.model.request.TeamRequest import net.halfbinary.scavengerhuntapi.model.response.PhotoResponse import net.halfbinary.scavengerhuntapi.model.response.TeamItemResponse import net.halfbinary.scavengerhuntapi.model.response.TeamResponse +import net.halfbinary.scavengerhuntapi.service.PhotoService import net.halfbinary.scavengerhuntapi.service.TeamService import org.springframework.core.io.InputStreamSource import org.springframework.http.ResponseEntity @@ -26,7 +27,7 @@ import org.springframework.web.multipart.MultipartFile @RestController @RequestMapping("hunt/{huntId}/team") -class TeamController(private val teamService: TeamService) { +class TeamController(private val teamService: TeamService, private val photoService: PhotoService) { @GetMapping @Operation(summary = "List all teams for the specified hunt") fun listHuntTeams(@PathVariable huntId: HuntId): ResponseEntity> { @@ -51,7 +52,8 @@ class TeamController(private val teamService: TeamService) { fun getItemForTeam(@PathVariable huntId: HuntId, @PathVariable teamId: TeamId, @PathVariable itemId: ItemId): ResponseEntity { - 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") @@ -87,7 +89,7 @@ class TeamController(private val teamService: TeamService) { @PathVariable itemId: ItemId, authentication: Authentication, @RequestParam file: MultipartFile): ResponseEntity { - 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()) } } \ No newline at end of file diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/PhotoStatus.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/PhotoStatus.kt new file mode 100644 index 0000000..f481377 --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/PhotoStatus.kt @@ -0,0 +1,8 @@ +package net.halfbinary.scavengerhuntapi.model + +enum class PhotoStatus { + SUBMITTED, + APPROVED, + REJECTED, + REMOVED +} \ No newline at end of file diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/TypeAlias.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/TypeAlias.kt index 14dcd6e..0c3a603 100644 --- a/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/TypeAlias.kt +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/TypeAlias.kt @@ -2,7 +2,6 @@ package net.halfbinary.scavengerhuntapi.model import java.util.* -typealias FoundId = UUID typealias HuntId = UUID typealias HunterId = UUID typealias ItemId = UUID diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/converter/PhotoConverter.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/converter/PhotoConverter.kt new file mode 100644 index 0000000..da95fea --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/converter/PhotoConverter.kt @@ -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 +) diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/domain/Photo.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/domain/Photo.kt new file mode 100644 index 0000000..9a2d80d --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/domain/Photo.kt @@ -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 +) diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/record/FoundRecord.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/record/PhotoRecord.kt similarity index 67% rename from src/main/kotlin/net/halfbinary/scavengerhuntapi/model/record/FoundRecord.kt rename to src/main/kotlin/net/halfbinary/scavengerhuntapi/model/record/PhotoRecord.kt index 9a1ab81..de8d0e1 100644 --- a/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/record/FoundRecord.kt +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/record/PhotoRecord.kt @@ -3,25 +3,25 @@ package net.halfbinary.scavengerhuntapi.model.record import jakarta.persistence.Entity import jakarta.persistence.Id 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.HunterId import net.halfbinary.scavengerhuntapi.model.ItemId +import net.halfbinary.scavengerhuntapi.model.PhotoId +import net.halfbinary.scavengerhuntapi.model.PhotoStatus import java.time.LocalDateTime /** * Represents a found Item for a Hunt by a Hunter */ @Entity -@Table(name = "found") -data class FoundRecord( +@Table(name = "photo") +data class PhotoRecord( @Id - val id: FoundId, + val id: PhotoId, val itemId: ItemId, val huntId: HuntId, val hunterId: HunterId, val foundDateTime: LocalDateTime, - val imageName: String, - val status: FoundStatus + val status: PhotoStatus, + val statusChangeDateTime: LocalDateTime, ) diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/response/PhotoResponse.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/response/PhotoResponse.kt index dfb0e68..16404cf 100644 --- a/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/response/PhotoResponse.kt +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/response/PhotoResponse.kt @@ -1,10 +1,14 @@ package net.halfbinary.scavengerhuntapi.model.response +import net.halfbinary.scavengerhuntapi.model.HunterId import net.halfbinary.scavengerhuntapi.model.PhotoId +import net.halfbinary.scavengerhuntapi.model.PhotoStatus import java.time.LocalDateTime data class PhotoResponse( val id: PhotoId, - val hunterName: String, - val photoUploadDateTime: LocalDateTime + val hunterId: HunterId, + val photoUploadDateTime: LocalDateTime, + val photoStatus: PhotoStatus, + val photoStatusChangeDateTime: LocalDateTime, ) diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/response/TeamItemResponse.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/response/TeamItemResponse.kt index ef8ced0..5b3bb67 100644 --- a/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/response/TeamItemResponse.kt +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/response/TeamItemResponse.kt @@ -2,10 +2,11 @@ package net.halfbinary.scavengerhuntapi.model.response import net.halfbinary.scavengerhuntapi.model.FoundStatus import net.halfbinary.scavengerhuntapi.model.ItemId +import java.time.LocalDateTime data class TeamItemResponse( val id: ItemId, - val itemName: String, - val hunterName: String, - val itemFoundStatus: FoundStatus + val hunterName: String?, + val itemFoundStatus: FoundStatus, + val itemStatusChangeDateTime: LocalDateTime, ) diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/repository/FoundRepository.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/repository/FoundRepository.kt deleted file mode 100644 index c863089..0000000 --- a/src/main/kotlin/net/halfbinary/scavengerhuntapi/repository/FoundRepository.kt +++ /dev/null @@ -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 \ No newline at end of file diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/repository/PhotoRepository.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/repository/PhotoRepository.kt new file mode 100644 index 0000000..b8df493 --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/repository/PhotoRepository.kt @@ -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 { + @Query(""" + SELECT * + FROM photo p + WHERE + p. + """, nativeQuery = true) + fun findPhotosByItemId(itemId: ItemId): List +} \ No newline at end of file diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/service/PhotoService.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/service/PhotoService.kt new file mode 100644 index 0000000..b09d159 --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/service/PhotoService.kt @@ -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() + } +} diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/service/S3StorageService.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/service/S3StorageService.kt new file mode 100644 index 0000000..b3fd66a --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/service/S3StorageService.kt @@ -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) + ) + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index c901910..4bee810 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -12,6 +12,14 @@ spring.datasource.password=${DB_PASSWORD} jwt.secret=${JWT_SECRET} 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.path=/docs/api-docs springdoc.swagger-ui.enabled=true