Compare commits
11 Commits
1585b6eb7d
...
feature/do
| Author | SHA1 | Date | |
|---|---|---|---|
| 6288cc6249 | |||
| b4f72a318d | |||
| eed5a0dd56 | |||
| 6327e0d034 | |||
| ac6f3a7014 | |||
| dbd988a573 | |||
| 67fb801812 | |||
| bc1bcf6e8d | |||
| aff0872e38 | |||
| 63e015400b | |||
| b349380c93 |
6
.dockerignore
Normal file
6
.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
.git/
|
||||
.gradle/
|
||||
build/
|
||||
Claude Notes/
|
||||
*.md
|
||||
.gitignore
|
||||
15
.woodpecker.yaml
Normal file
15
.woodpecker.yaml
Normal 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
14
Dockerfile
Normal 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"]
|
||||
50
README.md
50
README.md
@@ -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
78
docker-compose.yml
Normal 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:
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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')")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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() })
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
package net.halfbinary.scavengerhuntapi.error.exception
|
||||
|
||||
class ConflictException(override val message: String) : RuntimeException(message)
|
||||
@@ -0,0 +1,3 @@
|
||||
package net.halfbinary.scavengerhuntapi.error.exception
|
||||
|
||||
class ForbiddenException: RuntimeException("Access Denied.")
|
||||
@@ -4,6 +4,5 @@ enum class FoundStatus {
|
||||
NOT_FOUND,
|
||||
SUBMITTED,
|
||||
APPROVED,
|
||||
REJECTED,
|
||||
REMOVED
|
||||
REJECTED
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package net.halfbinary.scavengerhuntapi.model
|
||||
|
||||
enum class ImageVersion {
|
||||
ORIGINAL, LARGE, MEDIUM, SMALL
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
package net.halfbinary.scavengerhuntapi.model.domain
|
||||
|
||||
data class HunterLeaderboardEntry(
|
||||
val rank: Int,
|
||||
val hunterName: String,
|
||||
val score: Int
|
||||
)
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1,7 @@
|
||||
package net.halfbinary.scavengerhuntapi.model.domain
|
||||
|
||||
data class TeamLeaderboardEntry(
|
||||
val rank: Int,
|
||||
val teamName: String,
|
||||
val score: Int
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
package net.halfbinary.scavengerhuntapi.model.response
|
||||
|
||||
data class HunterLeaderboardResponse(
|
||||
val rank: Int,
|
||||
val hunterName: String,
|
||||
val score: Int
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package net.halfbinary.scavengerhuntapi.model.response
|
||||
|
||||
data class TeamLeaderboardResponse(
|
||||
val rank: Int,
|
||||
val teamName: String,
|
||||
val score: Int
|
||||
)
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user