18 Commits

Author SHA1 Message Date
6288cc6249 Adds docker and woodpecker files 2026-05-15 14:25:12 -05:00
b4f72a318d Adds team and hunter leaderboard endpoints 2026-05-15 14:05:55 -05:00
eed5a0dd56 Implements get photos for an item 2026-05-15 13:40:25 -05:00
6327e0d034 Fleshes out the README with useful information 2026-05-15 00:00:00 -05:00
ac6f3a7014 Implements remove photo endpoint 2026-05-14 23:55:05 -05:00
dbd988a573 Implements team item status endpoint 2026-05-14 23:35:53 -05:00
67fb801812 Implements photo review endpoint 2026-05-14 23:09:08 -05:00
bc1bcf6e8d Makes forbidden actions have clearer responses 2026-05-14 22:46:26 -05:00
aff0872e38 Adds image retrieval endpoint 2026-05-14 14:29:52 -05:00
63e015400b Merge pull request 'Implements get photo information endpoint' (#4) from feature/get-photo-info into main
Reviewed-on: #4
2026-05-14 18:10:49 +00:00
b349380c93 Implements get photo information endpoint 2026-05-14 13:01:42 -05:00
1585b6eb7d Removes response after uploading a photo, and preps things for get photo info endpoint 2026-05-14 11:40:59 -05:00
1dd904055c Adds file type detection, and beefs up error states for file uploads 2026-05-14 11:13:19 -05:00
5ca7a685dd Removes redundant DB data fields and adds photo submission endpoint along with MinIO support for image storage 2026-05-14 00:38:44 -05:00
863c824421 Adds descriptions to TeamController endpoints 2026-05-13 16:29:40 -05:00
9324cf2eb0 Implements getting items for a hunt 2026-05-13 16:09:10 -05:00
30c66527b9 Implements adding an item to a hunt 2026-05-13 15:47:03 -05:00
46132bb4fd Implements getting the team info for a Hunter in a Hunt 2026-05-13 00:08:08 -05:00
51 changed files with 963 additions and 61 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. REST API to support a community scavenger hunt app.
## Environment variables ## Prerequisites
* `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
## TODO: - Java 21
### User Endpoints - MariaDB
* upload photo for hunt item POST `/hunt/{huntId}/team/{teamId}/item/{itemId}/photo` - body: image binary - MinIO
* 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` ## Environment Variables
* list hunters with scores for hunt GET `/lead/hunt/{huntId}/hunter`
### Admin Endpoints | Variable | Description |
* approve photo for hunt item POST `/admin/hunt/{huntId}/team/{teamId}/item/{itemId}/photo/{photoId}` - body: approval status | --- | --- |
| `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`

View File

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

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.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

@@ -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

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

View File

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

View File

@@ -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

@@ -11,11 +11,12 @@ 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.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.security.core.Authentication import org.springframework.security.core.Authentication
import org.springframework.web.bind.annotation.GetMapping 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.PathVariable
import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestBody
@@ -26,7 +27,7 @@ import org.springframework.web.multipart.MultipartFile
@RestController @RestController
@RequestMapping("hunt/{huntId}/team") @RequestMapping("hunt/{huntId}/team")
class TeamController(private val teamService: TeamService) { class TeamController(private val teamService: TeamService, private val photoService: PhotoService) {
@GetMapping @GetMapping
@Operation(summary = "List all teams for the specified hunt") @Operation(summary = "List all teams for the specified hunt")
fun listHuntTeams(@PathVariable huntId: HuntId): ResponseEntity<List<TeamResponse>> { fun listHuntTeams(@PathVariable huntId: HuntId): ResponseEntity<List<TeamResponse>> {
@@ -41,47 +42,58 @@ class TeamController(private val teamService: TeamService) {
} }
@GetMapping("/{teamId}") @GetMapping("/{teamId}")
@Operation(summary = "Get team info for the specified hunt")
fun getTeam(@PathVariable huntId: HuntId, @PathVariable teamId: TeamId): ResponseEntity<TeamResponse> { fun getTeam(@PathVariable huntId: HuntId, @PathVariable teamId: TeamId): ResponseEntity<TeamResponse> {
return ResponseEntity.ok(teamService.getTeamFromHunt(huntId, teamId).toResponse()) return ResponseEntity.ok(teamService.getTeamFromHunt(huntId, teamId).toResponse())
} }
@GetMapping("/{teamId}/item/{itemId}") @GetMapping("/{teamId}/item/{itemId}")
@Operation(summary = "Get found/not found status about the Item for the specified Team, Hunt, and Item")
fun getItemForTeam(@PathVariable huntId: HuntId, fun getItemForTeam(@PathVariable huntId: HuntId,
@PathVariable teamId: TeamId, @PathVariable teamId: TeamId,
@PathVariable itemId: ItemId): ResponseEntity<TeamItemResponse> { @PathVariable itemId: ItemId,
TODO("Get found/not found status and photo information about the Item for the specified Team, Hunt, and Item") 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") @GetMapping("/{teamId}/item/{itemId}/photo")
@Operation(summary = "Get list of photo information for the specified Team, Hunt, and Item")
fun getItemPhotos(@PathVariable huntId: HuntId, fun getItemPhotos(@PathVariable huntId: HuntId,
@PathVariable teamId: TeamId, @PathVariable teamId: TeamId,
@PathVariable itemId: ItemId): ResponseEntity<List<PhotoResponse>> { @PathVariable itemId: ItemId,
TODO("Get list of photo information for the specified Team, Hunt, and Item") 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}") @GetMapping("/{teamId}/item/{itemId}/photo/{photoId}")
@Operation(summary = "Get photo information for the specified Team, Hunt, Item, and Photo")
fun getPhotoInfo(@PathVariable huntId: HuntId, fun getPhotoInfo(@PathVariable huntId: HuntId,
@PathVariable teamId: TeamId, @PathVariable teamId: TeamId,
@PathVariable itemId: ItemId, @PathVariable itemId: ItemId,
@PathVariable photoId: PhotoId): ResponseEntity<PhotoResponse> { @PathVariable photoId: PhotoId,
TODO("Get photo information for the specified Team, Hunt, Item, and Photo") authentication: Authentication): ResponseEntity<PhotoResponse> {
} return ResponseEntity.ok(photoService.getPhotoInfo(huntId, teamId, itemId, photoId, authentication.name))
@GetMapping("/{teamId}/item/{itemId}/photo/{photoId}/file")
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")
} }
@PostMapping("/{teamId}/item/{itemId}/photo") @PostMapping("/{teamId}/item/{itemId}/photo")
@Operation(summary = "Save photo information and store the binary file")
fun submitPhoto(@PathVariable huntId: HuntId, fun submitPhoto(@PathVariable huntId: HuntId,
@PathVariable teamId: TeamId, @PathVariable teamId: TeamId,
@PathVariable itemId: ItemId, @PathVariable itemId: ItemId,
authentication: Authentication, authentication: Authentication,
@RequestParam file: MultipartFile): ResponseEntity<PhotoResponse> { @RequestParam file: MultipartFile) {
TODO("Save photo information in the Photo table so that it relates to the specified Team, Hunt, Hunter, and Item, and store the binary file") photoService.submitPhoto(huntId, itemId, authentication.name, file)
} }
} }

View File

@@ -1,5 +1,8 @@
package net.halfbinary.scavengerhuntapi.error 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.InvalidEmailException
import net.halfbinary.scavengerhuntapi.error.exception.LoginFailedException import net.halfbinary.scavengerhuntapi.error.exception.LoginFailedException
import net.halfbinary.scavengerhuntapi.error.exception.NotFoundException import net.halfbinary.scavengerhuntapi.error.exception.NotFoundException
@@ -12,6 +15,9 @@ import org.springframework.web.bind.MethodArgumentNotValidException
import org.springframework.web.bind.annotation.ExceptionHandler import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestControllerAdvice import org.springframework.web.bind.annotation.RestControllerAdvice
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException
import org.springframework.web.multipart.MaxUploadSizeExceededException
import java.net.SocketTimeoutException
@RestControllerAdvice @RestControllerAdvice
@@ -43,6 +49,18 @@ class ExceptionHandler {
return e.message 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) @ExceptionHandler(HttpMessageNotReadableException::class)
@ResponseStatus(HttpStatus.BAD_REQUEST) @ResponseStatus(HttpStatus.BAD_REQUEST)
fun httpMessageNotReadableException(e: HttpMessageNotReadableException): Map<String, String?> { fun httpMessageNotReadableException(e: HttpMessageNotReadableException): Map<String, String?> {
@@ -68,6 +86,30 @@ class ExceptionHandler {
} }
} }
@ExceptionHandler(BadFileException::class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
fun badFileException(e: BadFileException): String? {
return e.message
}
@ExceptionHandler(MaxUploadSizeExceededException::class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
fun maxUploadSizeExceededException(e: MaxUploadSizeExceededException): String? {
return e.message
}
@ExceptionHandler(SocketTimeoutException::class)
@ResponseStatus(HttpStatus.SERVICE_UNAVAILABLE)
fun socketTimeoutException(): String {
return "Unable to connect. Try again later."
}
@ExceptionHandler(MethodArgumentTypeMismatchException::class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
fun argumentMismatchException(): String? {
return "Invalid parameter value."
}
private fun simpleMap(key: String, value: String?): Map<String, String?> { private fun simpleMap(key: String, value: String?): Map<String, String?> {
return mapOf(Pair(key, value)) return mapOf(Pair(key, value))
} }

View File

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

View File

@@ -0,0 +1,3 @@
package net.halfbinary.scavengerhuntapi.error.exception
class 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, NOT_FOUND,
SUBMITTED, SUBMITTED,
APPROVED, APPROVED,
REJECTED, REJECTED
REMOVED
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,19 @@
package net.halfbinary.scavengerhuntapi.model.domain
import net.halfbinary.scavengerhuntapi.model.HuntId
import net.halfbinary.scavengerhuntapi.model.HunterId
import net.halfbinary.scavengerhuntapi.model.ItemId
import net.halfbinary.scavengerhuntapi.model.PhotoId
import net.halfbinary.scavengerhuntapi.model.PhotoStatus
import java.time.LocalDateTime
import java.util.*
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

@@ -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

@@ -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

@@ -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

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

View File

@@ -5,7 +5,5 @@ import net.halfbinary.scavengerhuntapi.model.ItemId
data class TeamItemResponse( data class TeamItemResponse(
val id: ItemId, val id: ItemId,
val itemName: String,
val hunterName: String,
val itemFoundStatus: FoundStatus 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,9 +0,0 @@
package net.halfbinary.scavengerhuntapi.repository
import net.halfbinary.scavengerhuntapi.model.FoundId
import net.halfbinary.scavengerhuntapi.model.record.FoundRecord
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository
interface FoundRepository : JpaRepository<FoundRecord, FoundId>

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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.stereotype.Repository
@Repository
interface PhotoRepository : JpaRepository<PhotoRecord, PhotoId> {
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

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

View File

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

View File

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

View File

@@ -0,0 +1,201 @@
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
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
) {
fun submitPhoto(huntId: HuntId, itemId: ItemId, email: String, file: MultipartFile) {
val originalBytes = file.bytes
val fileType = fileProbeService.getFileType(originalBytes)
if(!fileProbeService.isImageType(fileType)) throw BadFileException("Not an image")
val originalAsJpeg = try {
toJpeg(file.bytes)
} catch (_: UnsupportedFormatException) {
throw BadFileException("Image type is not supported")
}
val hunter = hunterService.getHunterByEmail(email)
val now = LocalDateTime.now()
val photo = Photo(
itemId = itemId,
huntId = huntId,
hunterId = hunter.id,
foundDateTime = now,
status = PhotoStatus.SUBMITTED,
statusChangeDateTime = now
)
val savedRecord = photoRepository.save(photo.toRecord())
val baseName = savedRecord.id.toString()
s3StorageService.upload("${baseName}_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))
.scale(1.0)
.outputFormat("jpg")
.toOutputStream(output)
return output.toByteArray()
}
private fun resize(bytes: ByteArray, width: Int): ByteArray {
val output = ByteArrayOutputStream()
Thumbnails.of(ByteArrayInputStream(bytes))
.width(width)
.outputFormat("jpg")
.toOutputStream(output)
return output.toByteArray()
}
}

View File

@@ -0,0 +1,49 @@
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.GetObjectRequest
import software.amazon.awssdk.services.s3.model.ListObjectsV2Request
import software.amazon.awssdk.services.s3.model.PutObjectRequest
import java.io.InputStream
@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)
)
}
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.error.exception.NotFoundException
import net.halfbinary.scavengerhuntapi.model.HuntId import net.halfbinary.scavengerhuntapi.model.HuntId
import net.halfbinary.scavengerhuntapi.model.HunterId
import net.halfbinary.scavengerhuntapi.model.TeamId import net.halfbinary.scavengerhuntapi.model.TeamId
import net.halfbinary.scavengerhuntapi.model.converter.toDomain import net.halfbinary.scavengerhuntapi.model.converter.toDomain
import net.halfbinary.scavengerhuntapi.model.converter.toRecord import net.halfbinary.scavengerhuntapi.model.converter.toRecord
@@ -41,6 +42,18 @@ class TeamService(
.elementAt(0) .elementAt(0)
} }
fun getTeamForHunterInHunt(huntId: HuntId, email: String): Team {
val hunter = hunterRepository.findByEmail(email) ?: throw NotFoundException("No hunter with email $email found")
val hunterTeamIds = hunterTeamRepository.findByHunterId(hunter.id).map { it.teamId }.toSet()
return getTeamsForHunt(huntId)
.firstOrNull { it.id in hunterTeamIds }
?: throw NotFoundException("No team found for hunter $email in hunt $huntId")
}
fun getHunterIdsForTeam(teamId: TeamId): Set<HunterId> {
return hunterTeamRepository.findByTeamId(teamId).map { it.hunterId }.toSet()
}
fun joinTeam(teamId: TeamId, email: String) { fun joinTeam(teamId: TeamId, email: String) {
val hunter = hunterRepository.findByEmail(email) ?: throw NotFoundException("No hunter with email $email found") val hunter = hunterRepository.findByEmail(email) ?: throw NotFoundException("No hunter with email $email found")
hunterTeamRepository.save(HunterTeamRecord(UUID.randomUUID(), hunter.id, teamId)) hunterTeamRepository.save(HunterTeamRecord(UUID.randomUUID(), hunter.id, teamId))

View File

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