11 Commits

33 changed files with 594 additions and 49 deletions

6
.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
.git/
.gradle/
build/
Claude Notes/
*.md
.gitignore

15
.woodpecker.yaml Normal file
View File

@@ -0,0 +1,15 @@
when:
branch: main
event:
- push
- manual
steps:
- name: build
image: woodpeckerci/plugin-docker-buildx
settings:
repo: git.halfbinary.net/${CI_REPO_OWNER}/scavengerhunt-api
registry: git.halfbinary.net
tags: ${CI_PIPELINE_NUMBER}
username: ${CI_REPO_OWNER}
password:
from_secret: docker_password

14
Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
FROM amazoncorretto:21-alpine-jdk AS build
WORKDIR /app
COPY gradlew .
COPY gradle/ gradle/
COPY build.gradle.kts settings.gradle.kts ./
RUN ./gradlew dependencies --no-daemon
COPY src/ src/
RUN ./gradlew bootJar --no-daemon
FROM amazoncorretto:21-alpine-jdk
WORKDIR /app
COPY --from=build /app/build/libs/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

View File

@@ -2,17 +2,41 @@
REST API to support a community scavenger hunt app.
## Environment variables
* `DB_PASSWORD` Password for the database
* `DB_URL` JDBC URL for the database
* `DB_USER` Username for the database
* `JWT_SECRET` Secret pass for the JWT
## Prerequisites
## TODO:
### User Endpoints
* upload photo for hunt item POST `/hunt/{huntId}/team/{teamId}/item/{itemId}/photo` - body: image binary
* delete photo for hunt item DELETE `/hunt/{huntId}/team/{teamId}/item/{itemId}/photo/{photoId}`
* list hunt teams with scores for hunt `GET /lead/hunt/{huntId}/team`
* list hunters with scores for hunt GET `/lead/hunt/{huntId}/hunter`
### Admin Endpoints
* approve photo for hunt item POST `/admin/hunt/{huntId}/team/{teamId}/item/{itemId}/photo/{photoId}` - body: approval status
- Java 21
- MariaDB
- MinIO
## Environment Variables
| Variable | Description |
| --- | --- |
| `DB_URL` | JDBC URL for the MariaDB database |
| `DB_USER` | Database username |
| `DB_PASSWORD` | Database password |
| `JWT_SECRET` | Secret key used to sign JWTs |
| `MINIO_ENDPOINT` | MinIO server URL (e.g. `http://localhost:9000`) |
| `MINIO_ACCESS_KEY` | MinIO access key |
| `MINIO_SECRET_KEY` | MinIO secret key |
| `MINIO_BUCKET` | MinIO bucket name for photo storage |
## Running
```bash
./gradlew bootRun
```
## API Documentation
Swagger UI is available at `/docs/swagger-ui.html` when the application is running.
## Authentication
All endpoints except `/signup` and `/login` require a JWT bearer token.
1. Create an account: `POST /signup`
2. Log in: `POST /login` — returns an access token and a refresh token
3. Include the access token in requests: `Authorization: Bearer <token>`
4. Refresh an expired token: `POST /refresh`
5. Log out: `POST /logout`

78
docker-compose.yml Normal file
View File

@@ -0,0 +1,78 @@
# All services use host networking so inter-service traffic goes over loopback with no bridge overhead.
# Ports (all bound directly on the host):
# API: 8080
# MariaDB: 3306
# Adminer: 8888
# MinIO API: 9000
# MinIO Console: 9001
services:
mariadb:
image: mariadb:11
network_mode: host
environment:
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
MYSQL_DATABASE: ${DB_NAME}
MYSQL_USER: ${DB_USER}
MYSQL_PASSWORD: ${DB_PASSWORD}
volumes:
- mariadb_data:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
start_period: 10s
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
adminer:
image: adminer
network_mode: host
command: php -S [::]:8888 -t /var/www/html
restart: unless-stopped
minio:
image: minio/minio
network_mode: host
command: server /data --console-address :9001
environment:
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY}
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY}
volumes:
- minio_data:/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
start_period: 10s
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
api:
build: .
network_mode: host
environment:
DB_URL: jdbc:mariadb://localhost:3306/${DB_NAME}
DB_USER: ${DB_USER}
DB_PASSWORD: ${DB_PASSWORD}
JWT_SECRET: ${JWT_SECRET}
MINIO_ENDPOINT: http://localhost:9000
MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY}
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY}
MINIO_BUCKET: ${MINIO_BUCKET}
depends_on:
mariadb:
condition: service_healthy
minio:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
start_period: 30s
interval: 15s
timeout: 5s
retries: 5
restart: unless-stopped
volumes:
mariadb_data:
minio_data:

View File

@@ -0,0 +1,26 @@
package net.halfbinary.scavengerhuntapi.controller
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.tags.Tag
import jakarta.validation.Valid
import net.halfbinary.scavengerhuntapi.model.PhotoId
import net.halfbinary.scavengerhuntapi.model.request.ReviewPhotoRequest
import net.halfbinary.scavengerhuntapi.service.PhotoService
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.PatchMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("admin")
class AdminController(private val photoService: PhotoService) {
@PreAuthorize("hasRole('ADMIN')")
@Tag(name = "Admin")
@PatchMapping("/photo/{photoId}")
@Operation(summary = "Sets a review status for the specified photo")
fun reviewPhoto(@PathVariable photoId: PhotoId, @Valid @RequestBody request: ReviewPhotoRequest) {
photoService.updatePhotoStatus(photoId, request.status)
}
}

View File

@@ -30,7 +30,7 @@ class ItemController(private val huntService: HuntService) {
@GetMapping("/{itemId}")
fun getItem(@PathVariable huntId: HuntId, @PathVariable itemId: ItemId): ResponseEntity<ItemResponse> {
TODO("Get detailed information about the specified Item for the specified Hunt")
TODO("Maybe not needed: Get detailed information about the specified Item for the specified Hunt")
}
@PreAuthorize("hasRole('ADMIN')")

View File

@@ -0,0 +1,29 @@
package net.halfbinary.scavengerhuntapi.controller
import io.swagger.v3.oas.annotations.Operation
import net.halfbinary.scavengerhuntapi.model.ImageVersion
import net.halfbinary.scavengerhuntapi.model.PhotoId
import net.halfbinary.scavengerhuntapi.service.PhotoService
import org.springframework.core.io.InputStreamSource
import org.springframework.http.ResponseEntity
import org.springframework.security.core.Authentication
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("photo")
class PhotoController(private val photoService: PhotoService) {
@GetMapping("/{photoId}/file")
@Operation(summary = "Get the binary image information for the specified Photo")
fun getPhoto(authentication: Authentication,
@PathVariable photoId: PhotoId,
@RequestParam(defaultValue = "LARGE") version: ImageVersion): ResponseEntity<InputStreamSource> {
val photoFile = photoService.getPhotoFile(photoId, authentication.name, version)
return ResponseEntity.ok()
.contentType(photoFile.contentType)
.body(photoFile.resource)
}
}

View File

@@ -0,0 +1,30 @@
package net.halfbinary.scavengerhuntapi.controller
import io.swagger.v3.oas.annotations.Operation
import net.halfbinary.scavengerhuntapi.model.HuntId
import net.halfbinary.scavengerhuntapi.model.converter.toResponse
import net.halfbinary.scavengerhuntapi.model.response.HunterLeaderboardResponse
import net.halfbinary.scavengerhuntapi.model.response.TeamLeaderboardResponse
import net.halfbinary.scavengerhuntapi.service.StatsService
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("stats/lead/hunt/{huntId}")
class StatsController(private val statsService: StatsService) {
@GetMapping("/team")
@Operation(summary = "Ranked teams with current total scores for a hunt")
fun getTeamLeaderboard(@PathVariable huntId: HuntId): ResponseEntity<List<TeamLeaderboardResponse>> {
return ResponseEntity.ok(statsService.getTeamLeaderboard(huntId).map { it.toResponse() })
}
@GetMapping("/hunter")
@Operation(summary = "Ranked hunters with current total scores for a hunt")
fun getHunterLeaderboard(@PathVariable huntId: HuntId): ResponseEntity<List<HunterLeaderboardResponse>> {
return ResponseEntity.ok(statsService.getHunterLeaderboard(huntId).map { it.toResponse() })
}
}

View File

@@ -13,10 +13,10 @@ 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
import org.springframework.security.core.Authentication
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.RequestBody
@@ -48,20 +48,32 @@ class TeamController(private val teamService: TeamService, private val photoServ
}
@GetMapping("/{teamId}/item/{itemId}")
@Operation(summary = "Get found/not found status and photo information about the Item for the specified Team, Hunt, and Item")
@Operation(summary = "Get found/not found status about the Item for the specified Team, Hunt, and Item")
fun getItemForTeam(@PathVariable huntId: HuntId,
@PathVariable teamId: TeamId,
@PathVariable itemId: ItemId): ResponseEntity<TeamItemResponse> {
TODO("Get found/not found status about the Item for the specified Team, Hunt, and Item")
@PathVariable itemId: ItemId,
authentication: Authentication): ResponseEntity<TeamItemResponse> {
val foundStatus = photoService.getItemFoundStatus(huntId, teamId, itemId, authentication.name)
return ResponseEntity.ok(TeamItemResponse(id = itemId, itemFoundStatus = foundStatus))
}
@GetMapping("/{teamId}/item/{itemId}/photo")
@Operation(summary = "Get list of photo information for the specified Team, Hunt, and Item")
fun getItemPhotos(@PathVariable huntId: HuntId,
@PathVariable teamId: TeamId,
@PathVariable itemId: ItemId): ResponseEntity<List<PhotoResponse>> {
TODO("Get list of photo information for the specified Team, Hunt, and Item")
@PathVariable itemId: ItemId,
authentication: Authentication): ResponseEntity<List<PhotoResponse>> {
return ResponseEntity.ok(photoService.getItemPhotos(huntId, teamId, itemId, authentication.name))
}
@PatchMapping("/{teamId}/item/{itemId}/photo/{photoId}")
@Operation(summary = "Mark the specified Photo as removed")
fun removePhoto(@PathVariable huntId: HuntId,
@PathVariable teamId: TeamId,
@PathVariable itemId: ItemId,
@PathVariable photoId: PhotoId,
authentication: Authentication) {
photoService.removePhoto(huntId, teamId, itemId, photoId, authentication.name)
}
@GetMapping("/{teamId}/item/{itemId}/photo/{photoId}")
@@ -69,17 +81,9 @@ class TeamController(private val teamService: TeamService, private val photoServ
fun getPhotoInfo(@PathVariable huntId: HuntId,
@PathVariable teamId: TeamId,
@PathVariable itemId: ItemId,
@PathVariable photoId: PhotoId): ResponseEntity<PhotoResponse> {
TODO("Get photo information for the specified Team, Hunt, Item, and Photo. Join on the Hunter table to get the Hunter name. Also verify that the requesting user is either an admin or is on the same Hunt and Team as the Hunter who submitted the Photo")
}
@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,
@PathVariable teamId: TeamId,
@PathVariable itemId: ItemId,
@PathVariable photoId: PhotoId): ResponseEntity<InputStreamSource> {
TODO("Get the binary image information for the specified Team, Hunt, Item, and Photo")
@PathVariable photoId: PhotoId,
authentication: Authentication): ResponseEntity<PhotoResponse> {
return ResponseEntity.ok(photoService.getPhotoInfo(huntId, teamId, itemId, photoId, authentication.name))
}
@PostMapping("/{teamId}/item/{itemId}/photo")

View File

@@ -1,6 +1,8 @@
package net.halfbinary.scavengerhuntapi.error
import net.halfbinary.scavengerhuntapi.error.exception.BadFileException
import net.halfbinary.scavengerhuntapi.error.exception.ConflictException
import net.halfbinary.scavengerhuntapi.error.exception.ForbiddenException
import net.halfbinary.scavengerhuntapi.error.exception.InvalidEmailException
import net.halfbinary.scavengerhuntapi.error.exception.LoginFailedException
import net.halfbinary.scavengerhuntapi.error.exception.NotFoundException
@@ -13,6 +15,7 @@ import org.springframework.web.bind.MethodArgumentNotValidException
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestControllerAdvice
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException
import org.springframework.web.multipart.MaxUploadSizeExceededException
import java.net.SocketTimeoutException
@@ -46,6 +49,18 @@ class ExceptionHandler {
return e.message
}
@ExceptionHandler(ForbiddenException::class)
@ResponseStatus(HttpStatus.FORBIDDEN)
fun forbiddenException(e: ForbiddenException): String? {
return e.message
}
@ExceptionHandler(ConflictException::class)
@ResponseStatus(HttpStatus.CONFLICT)
fun conflictException(e: ConflictException): String? {
return e.message
}
@ExceptionHandler(HttpMessageNotReadableException::class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
fun httpMessageNotReadableException(e: HttpMessageNotReadableException): Map<String, String?> {
@@ -89,6 +104,12 @@ class ExceptionHandler {
return "Unable to connect. Try again later."
}
@ExceptionHandler(MethodArgumentTypeMismatchException::class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
fun argumentMismatchException(): String? {
return "Invalid parameter value."
}
private fun simpleMap(key: String, value: String?): Map<String, String?> {
return mapOf(Pair(key, value))
}

View File

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

View File

@@ -0,0 +1,3 @@
package net.halfbinary.scavengerhuntapi.error.exception
class ForbiddenException: RuntimeException("Access Denied.")

View File

@@ -4,6 +4,5 @@ enum class FoundStatus {
NOT_FOUND,
SUBMITTED,
APPROVED,
REJECTED,
REMOVED
REJECTED
}

View File

@@ -0,0 +1,5 @@
package net.halfbinary.scavengerhuntapi.model
enum class ImageVersion {
ORIGINAL, LARGE, MEDIUM, SMALL
}

View File

@@ -0,0 +1,10 @@
package net.halfbinary.scavengerhuntapi.model.converter
import net.halfbinary.scavengerhuntapi.model.domain.HunterLeaderboardEntry
import net.halfbinary.scavengerhuntapi.model.response.HunterLeaderboardResponse
fun HunterLeaderboardEntry.toResponse() = HunterLeaderboardResponse(
rank = rank,
hunterName = hunterName,
score = score
)

View File

@@ -15,6 +15,16 @@ fun Photo.toRecord() = PhotoRecord(
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,

View File

@@ -0,0 +1,10 @@
package net.halfbinary.scavengerhuntapi.model.converter
import net.halfbinary.scavengerhuntapi.model.domain.TeamLeaderboardEntry
import net.halfbinary.scavengerhuntapi.model.response.TeamLeaderboardResponse
fun TeamLeaderboardEntry.toResponse() = TeamLeaderboardResponse(
rank = rank,
teamName = teamName,
score = score
)

View File

@@ -0,0 +1,7 @@
package net.halfbinary.scavengerhuntapi.model.domain
data class HunterLeaderboardEntry(
val rank: Int,
val hunterName: String,
val score: Int
)

View File

@@ -6,7 +6,7 @@ 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
import java.util.*
data class Photo(
val id: PhotoId = UUID.randomUUID(),

View File

@@ -0,0 +1,6 @@
package net.halfbinary.scavengerhuntapi.model.domain
import org.springframework.core.io.InputStreamResource
import org.springframework.http.MediaType
data class PhotoFile(val resource: InputStreamResource, val contentType: MediaType)

View File

@@ -0,0 +1,7 @@
package net.halfbinary.scavengerhuntapi.model.domain
data class TeamLeaderboardEntry(
val rank: Int,
val teamName: String,
val score: Int
)

View File

@@ -0,0 +1,9 @@
package net.halfbinary.scavengerhuntapi.model.request
import jakarta.validation.constraints.NotBlank
import net.halfbinary.scavengerhuntapi.model.PhotoStatus
data class ReviewPhotoRequest(
@field:NotBlank(message = "Status must not be blank")
val status: PhotoStatus
)

View File

@@ -0,0 +1,7 @@
package net.halfbinary.scavengerhuntapi.model.response
data class HunterLeaderboardResponse(
val rank: Int,
val hunterName: String,
val score: Int
)

View File

@@ -2,11 +2,8 @@ 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 hunterName: String?,
val itemFoundStatus: FoundStatus,
val itemStatusChangeDateTime: LocalDateTime,
val itemFoundStatus: FoundStatus
)

View File

@@ -0,0 +1,7 @@
package net.halfbinary.scavengerhuntapi.model.response
data class TeamLeaderboardResponse(
val rank: Int,
val teamName: String,
val score: Int
)

View File

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

View File

@@ -1,19 +1,16 @@
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.PhotoStatus
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>
fun findByItemId(itemId: ItemId): List<PhotoRecord>
fun findByHuntIdAndItemId(huntId: HuntId, itemId: ItemId): List<PhotoRecord>
fun findByIdAndItemIdAndHuntId(id: PhotoId, itemId: ItemId, huntId: HuntId): PhotoRecord?
fun findByHuntIdAndStatus(huntId: HuntId, status: PhotoStatus): List<PhotoRecord>
}

View File

@@ -1,6 +1,7 @@
package net.halfbinary.scavengerhuntapi.service
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.domain.Hunter
import net.halfbinary.scavengerhuntapi.repository.HunterRepository
@@ -12,4 +13,8 @@ class HunterService(private val hunterRepository: HunterRepository) {
return hunterRepository.findByEmail(email)?.toDomain()
?: 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

@@ -3,12 +3,25 @@ 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.ConflictException
import net.halfbinary.scavengerhuntapi.error.exception.ForbiddenException
import net.halfbinary.scavengerhuntapi.error.exception.NotFoundException
import net.halfbinary.scavengerhuntapi.model.FoundStatus
import net.halfbinary.scavengerhuntapi.model.HuntId
import net.halfbinary.scavengerhuntapi.model.ImageVersion
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.domain.PhotoFile
import net.halfbinary.scavengerhuntapi.model.response.PhotoResponse
import net.halfbinary.scavengerhuntapi.repository.PhotoRepository
import org.springframework.core.io.InputStreamResource
import org.springframework.data.repository.findByIdOrNull
import org.springframework.http.MediaType
import org.springframework.stereotype.Service
import org.springframework.web.multipart.MultipartFile
@@ -16,10 +29,13 @@ import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.time.LocalDateTime
private const val PHOTO_NOT_FOUND = "Photo not found"
@Service
class PhotoService(
private val photoRepository: PhotoRepository,
private val hunterService: HunterService,
private val teamService: TeamService,
private val s3StorageService: S3StorageService,
private val fileProbeService: FileProbeService
) {
@@ -48,12 +64,123 @@ class PhotoService(
val savedRecord = photoRepository.save(photo.toRecord())
val baseName = savedRecord.id.toString()
s3StorageService.upload("$baseName${fileProbeService.getFileExtension(fileType)}", originalBytes, fileType)
s3StorageService.upload("${baseName}_original${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 = try {
teamService.getTeamForHunterInHunt(huntId, email)
} catch (_: NotFoundException) {
throw ForbiddenException()
}
if (team.id != teamId) throw ForbiddenException()
}
val submitter = hunterService.getHunterById(photoRecord.hunterId)
return photoRecord.toDomain().toResponse(submitter)
}
fun getPhotoFile(photoId: PhotoId, email: String, version: ImageVersion = ImageVersion.LARGE): PhotoFile {
val requestingHunter = hunterService.getHunterByEmail(email)
val photoRecord = photoRepository.findByIdOrNull(photoId)
?: throw NotFoundException(PHOTO_NOT_FOUND)
if (!requestingHunter.isAdmin) {
val submitter = hunterService.getHunterById(photoRecord.hunterId)
try {
val requestingTeam =
teamService.getTeamForHunterInHunt(photoRecord.huntId, requestingHunter.email)
val submitterTeam =
teamService.getTeamForHunterInHunt(photoRecord.huntId, submitter.email)
if (requestingTeam.id != submitterTeam.id) throw ForbiddenException()
} catch (_: NotFoundException) {
throw ForbiddenException()
}
}
val key = when (version) {
ImageVersion.ORIGINAL -> s3StorageService.findKeyByPrefix("${photoId}_original")
?: throw NotFoundException("Photo file not found")
ImageVersion.LARGE -> "${photoId}_large.jpg"
ImageVersion.MEDIUM -> "${photoId}_medium.jpg"
ImageVersion.SMALL -> "${photoId}_small.jpg"
}
val (stream, contentType) = s3StorageService.download(key)
return PhotoFile(InputStreamResource(stream), MediaType.parseMediaType(contentType))
}
fun getItemFoundStatus(huntId: HuntId, teamId: TeamId, itemId: ItemId, email: String): FoundStatus {
val requestingHunter = hunterService.getHunterByEmail(email)
if (!requestingHunter.isAdmin) {
val team = try {
teamService.getTeamForHunterInHunt(huntId, email)
} catch (_: NotFoundException) {
throw ForbiddenException()
}
if (team.id != teamId) throw ForbiddenException()
}
val teamHunterIds = teamService.getHunterIdsForTeam(teamId)
val photos = photoRepository.findByHuntIdAndItemId(huntId, itemId)
.filter { it.hunterId in teamHunterIds && it.status != PhotoStatus.REMOVED }
return when {
photos.any { it.status == PhotoStatus.APPROVED } -> FoundStatus.APPROVED
photos.any { it.status == PhotoStatus.REJECTED } -> FoundStatus.REJECTED
photos.any { it.status == PhotoStatus.SUBMITTED } -> FoundStatus.SUBMITTED
else -> FoundStatus.NOT_FOUND
}
}
fun removePhoto(huntId: HuntId, teamId: TeamId, itemId: ItemId, photoId: PhotoId, email: String) {
val photoRecord = photoRepository.findByIdAndItemIdAndHuntId(photoId, itemId, huntId)
?: throw NotFoundException(PHOTO_NOT_FOUND)
val team = try {
teamService.getTeamForHunterInHunt(huntId, email)
} catch (_: NotFoundException) {
throw ForbiddenException()
}
if (team.id != teamId) throw ForbiddenException()
if (photoRecord.status == PhotoStatus.APPROVED) throw ConflictException("Cannot remove an approved photo")
photoRepository.save(photoRecord.copy(status = PhotoStatus.REMOVED, statusChangeDateTime = LocalDateTime.now()))
}
fun getItemPhotos(huntId: HuntId, teamId: TeamId, itemId: ItemId, email: String): List<PhotoResponse> {
val requestingHunter = hunterService.getHunterByEmail(email)
if (!requestingHunter.isAdmin) {
val team = try {
teamService.getTeamForHunterInHunt(huntId, email)
} catch (_: NotFoundException) {
throw ForbiddenException()
}
if (team.id != teamId) throw ForbiddenException()
}
val teamHunterIds = teamService.getHunterIdsForTeam(teamId)
return photoRepository.findByHuntIdAndItemId(huntId, itemId)
.filter { it.hunterId in teamHunterIds && it.status != PhotoStatus.REMOVED }
.map { it.toDomain().toResponse(hunterService.getHunterById(it.hunterId)) }
}
fun updatePhotoStatus(photoId: PhotoId, status: PhotoStatus) {
val record = photoRepository.findByIdOrNull(photoId)
?: throw NotFoundException(PHOTO_NOT_FOUND)
photoRepository.save(record.copy(status = status, statusChangeDateTime = LocalDateTime.now()))
}
private fun toJpeg(bytes: ByteArray): ByteArray {
val output = ByteArrayOutputStream()
Thumbnails.of(ByteArrayInputStream(bytes))

View File

@@ -4,7 +4,10 @@ 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.GetObjectRequest
import software.amazon.awssdk.services.s3.model.ListObjectsV2Request
import software.amazon.awssdk.services.s3.model.PutObjectRequest
import java.io.InputStream
@Service
class S3StorageService(
@@ -22,4 +25,25 @@ class S3StorageService(
RequestBody.fromBytes(bytes)
)
}
fun findKeyByPrefix(prefix: String): String? {
val response = s3Client.listObjectsV2(
ListObjectsV2Request.builder()
.bucket(bucket)
.prefix(prefix)
.maxKeys(1)
.build()
)
return response.contents().firstOrNull()?.key()
}
fun download(key: String): Pair<InputStream, String> {
val response = s3Client.getObject(
GetObjectRequest.builder()
.bucket(bucket)
.key(key)
.build()
)
return Pair(response, response.response().contentType())
}
}

View File

@@ -0,0 +1,58 @@
package net.halfbinary.scavengerhuntapi.service
import net.halfbinary.scavengerhuntapi.model.HuntId
import net.halfbinary.scavengerhuntapi.model.PhotoStatus
import net.halfbinary.scavengerhuntapi.model.domain.HunterLeaderboardEntry
import net.halfbinary.scavengerhuntapi.model.domain.TeamLeaderboardEntry
import net.halfbinary.scavengerhuntapi.repository.ItemRepository
import net.halfbinary.scavengerhuntapi.repository.PhotoRepository
import org.springframework.stereotype.Service
@Service
class StatsService(
private val photoRepository: PhotoRepository,
private val itemRepository: ItemRepository,
private val teamService: TeamService,
private val hunterService: HunterService
) {
fun getTeamLeaderboard(huntId: HuntId): List<TeamLeaderboardEntry> {
val approvedPhotos = photoRepository.findByHuntIdAndStatus(huntId, PhotoStatus.APPROVED)
val itemPoints = itemRepository.findAllByHuntId(huntId).associate { it.id to it.points }
val teams = teamService.getListOfTeamsForHunt(huntId)
val sortedScores = teams.map { team ->
val teamHunterIds = teamService.getHunterIdsForTeam(team.id)
val score = approvedPhotos
.filter { it.hunterId in teamHunterIds }
.distinctBy { it.itemId }
.sumOf { itemPoints[it.itemId] ?: 0 }
team to score
}.sortedByDescending { (_, score) -> score }
var rank = 1
return sortedScores.mapIndexed { index, (team, score) ->
if (index > 0 && sortedScores[index - 1].second != score) rank = index + 1
TeamLeaderboardEntry(rank = rank, teamName = team.name, score = score)
}
}
fun getHunterLeaderboard(huntId: HuntId): List<HunterLeaderboardEntry> {
val approvedPhotos = photoRepository.findByHuntIdAndStatus(huntId, PhotoStatus.APPROVED)
val itemPoints = itemRepository.findAllByHuntId(huntId).associate { it.id to it.points }
val sortedScores = approvedPhotos
.groupBy { it.hunterId }
.map { (hunterId, photos) ->
val score = photos.distinctBy { it.itemId }.sumOf { itemPoints[it.itemId] ?: 0 }
hunterId to score
}
.sortedByDescending { (_, score) -> score }
var rank = 1
return sortedScores.mapIndexed { index, (hunterId, score) ->
if (index > 0 && sortedScores[index - 1].second != score) rank = index + 1
val hunter = hunterService.getHunterById(hunterId)
HunterLeaderboardEntry(rank = rank, hunterName = hunter.name, score = score)
}
}
}

View File

@@ -2,6 +2,7 @@ package net.halfbinary.scavengerhuntapi.service
import net.halfbinary.scavengerhuntapi.error.exception.NotFoundException
import net.halfbinary.scavengerhuntapi.model.HuntId
import net.halfbinary.scavengerhuntapi.model.HunterId
import net.halfbinary.scavengerhuntapi.model.TeamId
import net.halfbinary.scavengerhuntapi.model.converter.toDomain
import net.halfbinary.scavengerhuntapi.model.converter.toRecord
@@ -49,6 +50,10 @@ class TeamService(
?: throw NotFoundException("No team found for hunter $email in hunt $huntId")
}
fun getHunterIdsForTeam(teamId: TeamId): Set<HunterId> {
return hunterTeamRepository.findByTeamId(teamId).map { it.hunterId }.toSet()
}
fun joinTeam(teamId: TeamId, email: String) {
val hunter = hunterRepository.findByEmail(email) ?: throw NotFoundException("No hunter with email $email found")
hunterTeamRepository.save(HunterTeamRecord(UUID.randomUUID(), hunter.id, teamId))