25 Commits

Author SHA1 Message Date
3b5046f1db Converts all timestamps to UTC 2026-05-18 23:02:49 -05:00
5e2976180c Adds get ongoing Hunts endpoint 2026-05-18 23:01:59 -05:00
74391f8a46 Adds Team members list endpoint and Hunt details update endpoint 2026-05-18 17:10:52 -05:00
4049dbbdaa Corrects the type of field validation when reviewing a photo 2026-05-18 11:43:26 -05:00
8ff73cda2b Prevents Hunters from accessing hunt information before it starts 2026-05-18 11:41:22 -05:00
08d0b1730a Adds update and delete item endpoints 2026-05-18 08:59:58 -05:00
48b2ffd7b2 Streamlines the ongoing Hunt endpoint
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-05-17 22:11:52 -05:00
877e134166 Adds isAdmin to JWT
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-05-16 16:14:29 -05:00
ec2bb1bcc6 Adds Hunter name to login response
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-05-16 16:06:59 -05:00
6c3c94c5a3 Turns on CORS
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-05-16 15:54:24 -05:00
a34d2ddcf0 Opens up actuator endpoints
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-05-15 23:42:48 -05:00
b3801eb5e7 Updates Docker compose 2026-05-15 23:42:09 -05:00
4dfdb54bb4 Updates Dockerfile
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-05-15 14:32:15 -05:00
0a278530fb Merge pull request 'Adds docker and woodpecker files' (#5) from feature/docker into main
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Reviewed-on: #5
2026-05-15 19:27:57 +00:00
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
53 changed files with 802 additions and 102 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 chmod +x gradlew && ./gradlew dependencies --no-daemon
COPY src/ src/
RUN ./gradlew bootJar --no-daemon
FROM amazoncorretto:21-alpine-jdk
WORKDIR /app
COPY --from=build /app/build/libs/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

View File

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

70
docker-compose.yml Normal file
View File

@@ -0,0 +1,70 @@
services:
mariadb:
image: mariadb
environment:
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
MARIADB_DATABASE: ${DB_NAME}
MARIADB_USER: ${DB_USER}
MARIADB_PASSWORD: ${DB_PASSWORD}
ports:
- 3306:3306
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
ports:
- 8080:8080
restart: unless-stopped
minio:
image: minio/minio
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY}
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY}
ports:
- 15900:9000 # API
- 15901:9001 # Web UI
volumes:
- minio_data:/data
healthcheck:
test: ["CMD", "curl", "-f", "http://192.168.187.181:15900/minio/health/live"]
start_period: 10s
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
api:
image: git.halfbinary.net/aarbit/scavengerhunt-api:2
environment:
DB_URL: jdbc:mariadb://192.168.187.181:3306/${DB_NAME}
DB_USER: ${DB_USER}
DB_PASSWORD: ${DB_PASSWORD}
JWT_SECRET: ${JWT_SECRET}
MINIO_ENDPOINT: http://192.168.187.181:15900
MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY}
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY}
MINIO_BUCKET: ${MINIO_BUCKET}
ports:
- 15808:8080
depends_on:
mariadb:
condition: service_healthy
minio:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://192.168.187.181:15808/actuator/health"]
start_period: 30s
interval: 15s
timeout: 5s
retries: 5
restart: unless-stopped
volumes:
mariadb_data:
minio_data:

View File

@@ -27,9 +27,10 @@ class JwtUtil {
}
// Generate JWT token
fun generateToken(email: String): String {
fun generateToken(email: String, isAdmin: Boolean): String {
return Jwts.builder()
.subject(email)
.claim("isAdmin", isAdmin)
.issuedAt(Date())
.expiration(Date(System.currentTimeMillis() + jwtExpirationMs))
.signWith(key)

View File

@@ -7,7 +7,6 @@ import org.springframework.security.authentication.AuthenticationManager
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configurers.CorsConfigurer
import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer
import org.springframework.security.config.annotation.web.configurers.ExceptionHandlingConfigurer
import org.springframework.security.config.annotation.web.configurers.SessionManagementConfigurer
@@ -16,6 +15,10 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
import org.springframework.web.cors.CorsConfiguration
import org.springframework.web.cors.CorsConfigurationSource
import org.springframework.web.cors.UrlBasedCorsConfigurationSource
@Configuration
@@ -48,13 +51,25 @@ class SecurityConfig(private val authEntrypointJwt: AuthEntrypointJwt,
return BCryptPasswordEncoder()
}
@Bean
fun corsConfigurationSource(): CorsConfigurationSource {
val config = CorsConfiguration()
config.allowedOriginPatterns = listOf("*")
config.allowedMethods = listOf("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
config.allowedHeaders = listOf("*")
config.allowCredentials = true
val source = UrlBasedCorsConfigurationSource()
source.registerCorsConfiguration("/**", config)
return source
}
@Bean
@Throws(Exception::class)
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain? {
// Updated configuration for Spring Security 6.x
http
.csrf { csrf: CsrfConfigurer<HttpSecurity> -> csrf.disable() } // Disable CSRF
.cors { cors: CorsConfigurer<HttpSecurity> -> cors.disable() } // Disable CORS (or configure if needed)
.csrf { csrf: CsrfConfigurer<HttpSecurity> -> csrf.disable() }
.cors { cors -> cors.configurationSource(corsConfigurationSource()) }
.exceptionHandling { exceptionHandling: ExceptionHandlingConfigurer<HttpSecurity> ->
exceptionHandling.authenticationEntryPoint(
authEntrypointJwt
@@ -67,7 +82,7 @@ class SecurityConfig(private val authEntrypointJwt: AuthEntrypointJwt,
}
.authorizeHttpRequests { authorizeRequests ->
authorizeRequests
.requestMatchers("/auth/**", "/signup", "/docs/**")
.requestMatchers("/auth/**", "/signup", "/docs/**", "/actuator/**")
.permitAll()
.anyRequest().authenticated()
}

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

@@ -24,9 +24,9 @@ class AuthController(private val loginService: LoginService, private val jwtUtil
@PostMapping("/login")
fun login(@Valid @RequestBody body: LoginRequest): ResponseEntity<LoginResponse> {
val result = loginService.login(body.toDomain())
val accessToken = jwtUtils.generateToken(result.email)
val accessToken = jwtUtils.generateToken(result.email, result.isAdmin)
val refreshToken = refreshTokenService.generateRefreshToken(result.email)
val loginResponse = LoginResponse(accessToken, refreshToken)
val loginResponse = LoginResponse(accessToken, refreshToken, result.name)
return ResponseEntity.ok(loginResponse)
}

View File

@@ -9,11 +9,13 @@ import net.halfbinary.scavengerhuntapi.model.converter.toDomain
import net.halfbinary.scavengerhuntapi.model.converter.toResponse
import net.halfbinary.scavengerhuntapi.model.request.HuntCreateRequest
import net.halfbinary.scavengerhuntapi.model.request.HuntStatus
import net.halfbinary.scavengerhuntapi.model.request.HuntUpdateRequest
import net.halfbinary.scavengerhuntapi.model.response.HuntResponse
import net.halfbinary.scavengerhuntapi.service.HuntService
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.PatchMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
@@ -44,6 +46,12 @@ class HuntController(private val huntService: HuntService) {
return ResponseEntity.ok(huntService.getAllHunts(HuntStatus.UNSTARTED).map { it.toResponse() })
}
@GetMapping("/ongoing")
@Operation(summary = "Gets list of all ongoing Hunts")
fun getOngoingHunts(): ResponseEntity<List<HuntResponse>> {
return ResponseEntity.ok(huntService.getAllHunts(HuntStatus.ONGOING).map { it.toResponse() })
}
@PreAuthorize("hasRole('ADMIN')")
@Tag(name = "Admin")
@PostMapping
@@ -52,6 +60,14 @@ class HuntController(private val huntService: HuntService) {
return ResponseEntity.ok(huntService.createHunt(huntRequest.toDomain()).toResponse())
}
@PreAuthorize("hasRole('ADMIN')")
@Tag(name = "Admin")
@PatchMapping("/{id}")
@Operation(summary = "Updates details of the specified Hunt")
fun updateHunt(@PathVariable("id") huntId: HuntId, @RequestBody body: HuntUpdateRequest): ResponseEntity<HuntResponse> {
return ResponseEntity.ok(huntService.updateHunt(huntId, body).toResponse())
}
@PreAuthorize("hasRole('ADMIN')")
@Tag(name = "Admin")
@GetMapping("/hunter/{hunterId}")

View File

@@ -16,7 +16,6 @@ import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
@RestController
@@ -27,13 +26,13 @@ class HunterController(private val hunterService: HunterService,
@GetMapping("/hunt/ongoing")
@Operation(summary = "Gets list of all currently running Hunts (filtered by the calling hunter)")
fun getOngoingHunts(authentication: Authentication, @RequestParam status: HuntStatus?): ResponseEntity<List<HuntResponse>> {
fun getOngoingHunts(authentication: Authentication): ResponseEntity<List<HuntResponse>> {
val email = authentication.name
val isAdmin = hunterService.getHunterByEmail(email).isAdmin
return if(isAdmin) {
ResponseEntity.ok(huntService.getAllHunts(HuntStatus.ONGOING).map { it.toResponse() })
} else {
ResponseEntity.ok(huntService.getHuntsByEmail(email, status).map { it.toResponse() })
ResponseEntity.ok(huntService.getHuntsByEmail(email, HuntStatus.ONGOING).map { it.toResponse() })
}
}

View File

@@ -8,11 +8,15 @@ 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.ItemUpdateRequest
import net.halfbinary.scavengerhuntapi.model.response.ItemResponse
import net.halfbinary.scavengerhuntapi.service.HuntService
import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.security.core.Authentication
import org.springframework.web.bind.annotation.DeleteMapping
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
@@ -24,13 +28,13 @@ import org.springframework.web.bind.annotation.RestController
class ItemController(private val huntService: HuntService) {
@GetMapping
fun getItemsForHunt(@PathVariable huntId: HuntId): ResponseEntity<List<ItemResponse>> {
return ResponseEntity.ok(huntService.getItemsForHunt(huntId).map { it.toResponse() })
fun getItemsForHunt(@PathVariable huntId: HuntId, authentication: Authentication): ResponseEntity<List<ItemResponse>> {
return ResponseEntity.ok(huntService.getItemsForHunt(huntId, authentication.name).map { it.toResponse() })
}
@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')")
@@ -41,4 +45,21 @@ class ItemController(private val huntService: HuntService) {
return ResponseEntity.ok(huntService.addItemToHunt(huntId, body.toDomain()).toResponse())
}
@PreAuthorize("hasRole('ADMIN')")
@Tag(name = "Admin")
@PatchMapping("/{itemId}")
@Operation(summary = "Updates name and/or points for the specified Item in the specified Hunt")
fun updateItem(@PathVariable huntId: HuntId, @PathVariable itemId: ItemId, @RequestBody body: ItemUpdateRequest): ResponseEntity<ItemResponse> {
return ResponseEntity.ok(huntService.updateItem(huntId, itemId, body).toResponse())
}
@PreAuthorize("hasRole('ADMIN')")
@Tag(name = "Admin")
@DeleteMapping("/{itemId}")
@Operation(summary = "Deletes the specified Item from the specified Hunt")
fun deleteItem(@PathVariable huntId: HuntId, @PathVariable itemId: ItemId): ResponseEntity<Unit> {
huntService.deleteItem(huntId, itemId)
return ResponseEntity.noContent().build()
}
}

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

@@ -7,16 +7,18 @@ import net.halfbinary.scavengerhuntapi.model.ItemId
import net.halfbinary.scavengerhuntapi.model.PhotoId
import net.halfbinary.scavengerhuntapi.model.TeamId
import net.halfbinary.scavengerhuntapi.model.converter.toResponse
import net.halfbinary.scavengerhuntapi.model.converter.toSummaryResponse
import net.halfbinary.scavengerhuntapi.model.request.TeamRequest
import net.halfbinary.scavengerhuntapi.model.response.HunterSummaryResponse
import net.halfbinary.scavengerhuntapi.model.response.PhotoResponse
import net.halfbinary.scavengerhuntapi.model.response.TeamItemResponse
import net.halfbinary.scavengerhuntapi.model.response.TeamResponse
import net.halfbinary.scavengerhuntapi.service.PhotoService
import net.halfbinary.scavengerhuntapi.service.TeamService
import org.springframework.core.io.InputStreamSource
import org.springframework.http.ResponseEntity
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
@@ -47,21 +49,39 @@ class TeamController(private val teamService: TeamService, private val photoServ
return ResponseEntity.ok(teamService.getTeamFromHunt(huntId, teamId).toResponse())
}
@GetMapping("/{teamId}/hunter")
@Operation(summary = "Get all Hunters for the specified Team in the specified Hunt")
fun getHuntersForTeam(@PathVariable huntId: HuntId, @PathVariable teamId: TeamId): ResponseEntity<List<HunterSummaryResponse>> {
return ResponseEntity.ok(teamService.getHuntersForTeam(huntId, teamId).map { it.toSummaryResponse() })
}
@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 +89,9 @@ class TeamController(private val teamService: TeamService, private val photoServ
fun getPhotoInfo(@PathVariable huntId: HuntId,
@PathVariable teamId: TeamId,
@PathVariable itemId: ItemId,
@PathVariable photoId: PhotoId): ResponseEntity<PhotoResponse> {
TODO("Get photo information for the specified Team, Hunt, Item, and Photo. Join on the Hunter table to get the Hunter name. Also verify that the requesting user is either an admin or is on the same Hunt and Team as the Hunter who submitted the Photo")
}
@GetMapping("/{teamId}/item/{itemId}/photo/{photoId}/file")
@Operation(summary = "Get the binary image information for the specified Team, Hunt, Item, and Photo")
fun getPhoto(@PathVariable huntId: HuntId,
@PathVariable teamId: TeamId,
@PathVariable itemId: ItemId,
@PathVariable photoId: PhotoId): ResponseEntity<InputStreamSource> {
TODO("Get the binary image information for the specified Team, Hunt, Item, and Photo")
@PathVariable photoId: PhotoId,
authentication: Authentication): ResponseEntity<PhotoResponse> {
return ResponseEntity.ok(photoService.getPhotoInfo(huntId, teamId, itemId, photoId, authentication.name))
}
@PostMapping("/{teamId}/item/{itemId}/photo")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ package net.halfbinary.scavengerhuntapi.model.converter
import net.halfbinary.scavengerhuntapi.model.domain.Hunter
import net.halfbinary.scavengerhuntapi.model.record.HunterRecord
import net.halfbinary.scavengerhuntapi.model.request.HunterSignupRequest
import net.halfbinary.scavengerhuntapi.model.response.HunterSummaryResponse
fun HunterSignupRequest.toDomain(): Hunter {
return Hunter(
@@ -19,4 +20,8 @@ fun Hunter.toRecord(): HunterRecord {
fun HunterRecord.toDomain(): Hunter {
return Hunter(id, email, name, password, isAdmin)
}
fun Hunter.toSummaryResponse(): HunterSummaryResponse {
return HunterSummaryResponse(id, name)
}

View File

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

View File

@@ -15,6 +15,16 @@ fun Photo.toRecord() = PhotoRecord(
statusChangeDateTime = statusChangeDateTime
)
fun PhotoRecord.toDomain() = Photo(
id = id,
itemId = itemId,
huntId = huntId,
hunterId = hunterId,
foundDateTime = foundDateTime,
status = status,
statusChangeDateTime = statusChangeDateTime
)
fun Photo.toResponse(hunter: Hunter) = PhotoResponse(
id = id,
hunterName = hunter.name,

View File

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

View File

@@ -1,13 +1,16 @@
package net.halfbinary.scavengerhuntapi.model.domain
import net.halfbinary.scavengerhuntapi.model.HuntId
import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.util.*
data class Hunt(
val id: HuntId = UUID.randomUUID(),
val title: String,
val startDateTime: LocalDateTime,
val endDateTime: LocalDateTime,
val startDateTime: OffsetDateTime,
val endDateTime: OffsetDateTime,
val isTerminated: Boolean
)
) {
val isOngoing: Boolean
get() = !isTerminated && startDateTime < OffsetDateTime.now() && endDateTime > OffsetDateTime.now()
}

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

@@ -5,15 +5,15 @@ import net.halfbinary.scavengerhuntapi.model.HunterId
import net.halfbinary.scavengerhuntapi.model.ItemId
import net.halfbinary.scavengerhuntapi.model.PhotoId
import net.halfbinary.scavengerhuntapi.model.PhotoStatus
import java.time.LocalDateTime
import java.util.UUID
import java.time.OffsetDateTime
import java.util.*
data class Photo(
val id: PhotoId = UUID.randomUUID(),
val itemId: ItemId,
val huntId: HuntId,
val hunterId: HunterId,
val foundDateTime: LocalDateTime,
val foundDateTime: OffsetDateTime,
val status: PhotoStatus,
val statusChangeDateTime: LocalDateTime
val statusChangeDateTime: OffsetDateTime
)

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

@@ -4,7 +4,7 @@ import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table
import net.halfbinary.scavengerhuntapi.model.HuntId
import java.time.LocalDateTime
import java.time.OffsetDateTime
/**
* Represents a scavenger hunt event
@@ -16,7 +16,7 @@ data class HuntRecord(
@Id
val id: HuntId,
val title: String,
val startDateTime: LocalDateTime,
val endDateTime: LocalDateTime,
val startDateTime: OffsetDateTime,
val endDateTime: OffsetDateTime,
val isTerminated: Boolean
)

View File

@@ -8,7 +8,7 @@ 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.time.OffsetDateTime
/**
* Represents a found Item for a Hunt by a Hunter
@@ -21,7 +21,7 @@ data class PhotoRecord(
val itemId: ItemId,
val huntId: HuntId,
val hunterId: HunterId,
val foundDateTime: LocalDateTime,
val foundDateTime: OffsetDateTime,
val status: PhotoStatus,
val statusChangeDateTime: LocalDateTime,
val statusChangeDateTime: OffsetDateTime,
)

View File

@@ -4,7 +4,7 @@ import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table
import net.halfbinary.scavengerhuntapi.model.RefreshId
import java.time.LocalDateTime
import java.time.OffsetDateTime
@Entity
@Table(name = "refresh_token")
@@ -12,5 +12,5 @@ data class RefreshTokenRecord(
@Id
val token: RefreshId,
val email: String,
val expiryDateTime: LocalDateTime
val expiryDateTime: OffsetDateTime
)

View File

@@ -2,13 +2,13 @@ package net.halfbinary.scavengerhuntapi.model.request
import jakarta.validation.constraints.Future
import jakarta.validation.constraints.NotBlank
import java.time.LocalDateTime
import java.time.OffsetDateTime
data class HuntCreateRequest(
@field:NotBlank(message = "Hunt title is required")
val title: String,
@field:Future
val startDateTime: LocalDateTime,
val startDateTime: OffsetDateTime,
@field:Future
val endDateTime: LocalDateTime,
val endDateTime: OffsetDateTime,
)

View File

@@ -0,0 +1,10 @@
package net.halfbinary.scavengerhuntapi.model.request
import java.time.OffsetDateTime
data class HuntUpdateRequest(
val title: String?,
val startDateTime: OffsetDateTime?,
val endDateTime: OffsetDateTime?,
val isTerminated: Boolean?
)

View File

@@ -0,0 +1,6 @@
package net.halfbinary.scavengerhuntapi.model.request
data class ItemUpdateRequest(
val name: String?,
val points: Int?
)

View File

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

View File

@@ -1,12 +1,12 @@
package net.halfbinary.scavengerhuntapi.model.response
import net.halfbinary.scavengerhuntapi.model.HuntId
import java.time.LocalDateTime
import java.time.OffsetDateTime
data class HuntResponse(
val id: HuntId,
val title: String,
val startDateTime: LocalDateTime,
val endDateTime: LocalDateTime,
val startDateTime: OffsetDateTime,
val endDateTime: OffsetDateTime,
val isTerminated: Boolean
)

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

@@ -0,0 +1,8 @@
package net.halfbinary.scavengerhuntapi.model.response
import net.halfbinary.scavengerhuntapi.model.HunterId
data class HunterSummaryResponse(
val id: HunterId,
val name: String
)

View File

@@ -4,5 +4,6 @@ import net.halfbinary.scavengerhuntapi.model.RefreshId
data class LoginResponse(
val accessToken: String,
val refreshToken: RefreshId
val refreshToken: RefreshId,
val name: String
)

View File

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

View File

@@ -2,11 +2,8 @@ package net.halfbinary.scavengerhuntapi.model.response
import net.halfbinary.scavengerhuntapi.model.FoundStatus
import net.halfbinary.scavengerhuntapi.model.ItemId
import java.time.LocalDateTime
data class TeamItemResponse(
val id: ItemId,
val hunterName: String?,
val itemFoundStatus: FoundStatus,
val itemStatusChangeDateTime: LocalDateTime,
val itemFoundStatus: FoundStatus
)

View File

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

View File

@@ -1,9 +1,13 @@
package net.halfbinary.scavengerhuntapi.repository
import net.halfbinary.scavengerhuntapi.model.HuntId
import net.halfbinary.scavengerhuntapi.model.ItemId
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>
interface HuntItemRepository : JpaRepository<HuntItemRecord, UUID> {
fun findByHuntIdAndItemId(huntId: HuntId, itemId: ItemId): HuntItemRecord?
}

View File

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

View File

@@ -1,19 +1,16 @@
package net.halfbinary.scavengerhuntapi.repository
import net.halfbinary.scavengerhuntapi.model.HuntId
import net.halfbinary.scavengerhuntapi.model.ItemId
import net.halfbinary.scavengerhuntapi.model.PhotoId
import net.halfbinary.scavengerhuntapi.model.PhotoStatus
import net.halfbinary.scavengerhuntapi.model.record.PhotoRecord
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import org.springframework.stereotype.Repository
@Repository
interface PhotoRepository : JpaRepository<PhotoRecord, PhotoId> {
@Query("""
SELECT *
FROM photo p
WHERE
p.
""", nativeQuery = true)
fun findPhotosByItemId(itemId: ItemId): List<PhotoRecord>
fun findByItemId(itemId: ItemId): List<PhotoRecord>
fun findByHuntIdAndItemId(huntId: HuntId, itemId: ItemId): List<PhotoRecord>
fun findByIdAndItemIdAndHuntId(id: PhotoId, itemId: ItemId, huntId: HuntId): PhotoRecord?
fun findByHuntIdAndStatus(huntId: HuntId, status: PhotoStatus): List<PhotoRecord>
}

View File

@@ -1,26 +1,31 @@
package net.halfbinary.scavengerhuntapi.service
import net.halfbinary.scavengerhuntapi.error.exception.ForbiddenException
import net.halfbinary.scavengerhuntapi.error.exception.NotFoundException
import net.halfbinary.scavengerhuntapi.model.HuntId
import net.halfbinary.scavengerhuntapi.model.HunterId
import net.halfbinary.scavengerhuntapi.model.ItemId
import net.halfbinary.scavengerhuntapi.model.converter.toDomain
import net.halfbinary.scavengerhuntapi.model.converter.toRecord
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.HuntUpdateRequest
import net.halfbinary.scavengerhuntapi.model.request.ItemUpdateRequest
import net.halfbinary.scavengerhuntapi.repository.HuntItemRepository
import net.halfbinary.scavengerhuntapi.repository.HuntRepository
import net.halfbinary.scavengerhuntapi.repository.ItemRepository
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import java.time.LocalDateTime
import java.time.OffsetDateTime
@Service
class HuntService(
private val huntRepository: HuntRepository,
private val itemRepository: ItemRepository,
private val huntItemRepository: HuntItemRepository
private val huntItemRepository: HuntItemRepository,
private val hunterService: HunterService
) {
fun getHunt(huntId: HuntId): Hunt {
return huntRepository.findByIdOrNull(huntId)?.toDomain() ?: throw NotFoundException("No hunt with id $huntId found")
@@ -44,16 +49,16 @@ class HuntService(
val filteredHunts = when (status) {
HuntStatus.ONGOING -> {
allHunts
.filter { !it.isTerminated && it.startDateTime < LocalDateTime.now() && it.endDateTime > LocalDateTime.now() }
.filter { !it.isTerminated && it.startDateTime < OffsetDateTime.now() && it.endDateTime > OffsetDateTime.now() }
.toList()
}
HuntStatus.CLOSED -> {
allHunts
.filter { it.isTerminated || it.endDateTime < LocalDateTime.now() }
.filter { it.isTerminated || it.endDateTime < OffsetDateTime.now() }
}
HuntStatus.UNSTARTED -> {
allHunts
.filter { !it.isTerminated && it.startDateTime > LocalDateTime.now() }
.filter { !it.isTerminated && it.startDateTime > OffsetDateTime.now() }
}
else -> { allHunts }
}
@@ -64,8 +69,22 @@ class HuntService(
return huntRepository.save(hunt.toRecord()).toDomain()
}
fun getItemsForHunt(huntId: HuntId): List<Item> {
huntRepository.findByIdOrNull(huntId) ?: throw NotFoundException("No hunt with id $huntId found")
fun updateHunt(huntId: HuntId, request: HuntUpdateRequest): Hunt {
val existing = huntRepository.findByIdOrNull(huntId)
?: throw NotFoundException("No hunt with id $huntId found")
val updated = existing.copy(
title = request.title ?: existing.title,
startDateTime = request.startDateTime ?: existing.startDateTime,
endDateTime = request.endDateTime ?: existing.endDateTime,
isTerminated = request.isTerminated ?: existing.isTerminated
)
return huntRepository.save(updated).toDomain()
}
fun getItemsForHunt(huntId: HuntId, email: String): List<Item> {
val hunt = huntRepository.findByIdOrNull(huntId)?.toDomain() ?: throw NotFoundException("No hunt with id $huntId found")
val hunter = hunterService.getHunterByEmail(email)
if (!hunter.isAdmin && !hunt.isOngoing) throw ForbiddenException()
return itemRepository.findAllByHuntId(huntId).map { it.toDomain() }
}
@@ -75,4 +94,23 @@ class HuntService(
huntItemRepository.save(HuntItem(huntId = huntId, itemId = savedItem.id).toRecord())
return savedItem
}
fun updateItem(huntId: HuntId, itemId: ItemId, request: ItemUpdateRequest): Item {
huntItemRepository.findByHuntIdAndItemId(huntId, itemId)
?: throw NotFoundException("No item with id $itemId found in hunt $huntId")
val existing = itemRepository.findByIdOrNull(itemId)
?: throw NotFoundException("No item with id $itemId found")
val updated = existing.copy(
name = request.name ?: existing.name,
points = request.points ?: existing.points
)
return itemRepository.save(updated).toDomain()
}
fun deleteItem(huntId: HuntId, itemId: ItemId) {
val huntItem = huntItemRepository.findByHuntIdAndItemId(huntId, itemId)
?: throw NotFoundException("No item with id $itemId found in hunt $huntId")
huntItemRepository.delete(huntItem)
itemRepository.deleteById(itemId)
}
}

View File

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

View File

@@ -3,27 +3,48 @@ 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
import java.time.OffsetDateTime
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 huntService: HuntService,
private val s3StorageService: S3StorageService,
private val fileProbeService: FileProbeService
) {
fun submitPhoto(huntId: HuntId, itemId: ItemId, email: String, file: MultipartFile) {
val hunter = hunterService.getHunterByEmail(email)
val hunt = huntService.getHunt(huntId)
if (!hunter.isAdmin && !hunt.isOngoing) throw ForbiddenException()
val originalBytes = file.bytes
val fileType = fileProbeService.getFileType(originalBytes)
@@ -35,8 +56,7 @@ class PhotoService(
throw BadFileException("Image type is not supported")
}
val hunter = hunterService.getHunterByEmail(email)
val now = LocalDateTime.now()
val now = OffsetDateTime.now()
val photo = Photo(
itemId = itemId,
huntId = huntId,
@@ -48,12 +68,138 @@ 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 hunt = huntService.getHunt(huntId)
if (!hunt.isOngoing) throw ForbiddenException()
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 hunt = huntService.getHunt(huntId)
if (!hunt.isOngoing) throw ForbiddenException()
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 requestingHunter = hunterService.getHunterByEmail(email)
if (!requestingHunter.isAdmin) {
val hunt = huntService.getHunt(huntId)
if (!hunt.isOngoing) throw ForbiddenException()
}
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()
}
if (photoRecord.status == PhotoStatus.APPROVED) throw ConflictException("Cannot remove an approved photo")
photoRepository.save(photoRecord.copy(status = PhotoStatus.REMOVED, statusChangeDateTime = OffsetDateTime.now()))
}
fun getItemPhotos(huntId: HuntId, teamId: TeamId, itemId: ItemId, email: String): List<PhotoResponse> {
val requestingHunter = hunterService.getHunterByEmail(email)
if (!requestingHunter.isAdmin) {
val hunt = huntService.getHunt(huntId)
if (!hunt.isOngoing) throw ForbiddenException()
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 = OffsetDateTime.now()))
}
private fun toJpeg(bytes: ByteArray): ByteArray {
val output = ByteArrayOutputStream()
Thumbnails.of(ByteArrayInputStream(bytes))

View File

@@ -5,15 +5,16 @@ import net.halfbinary.scavengerhuntapi.error.exception.ExpiredRefreshTokenExcept
import net.halfbinary.scavengerhuntapi.error.exception.InvalidRefreshTokenException
import net.halfbinary.scavengerhuntapi.model.RefreshId
import net.halfbinary.scavengerhuntapi.model.record.RefreshTokenRecord
import net.halfbinary.scavengerhuntapi.repository.HunterRepository
import net.halfbinary.scavengerhuntapi.repository.RefreshTokenRepository
import org.slf4j.LoggerFactory
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.time.temporal.ChronoUnit
@Service
class RefreshTokenService(private val refreshTokenRepository: RefreshTokenRepository, private val jwtUtil: JwtUtil) {
class RefreshTokenService(private val refreshTokenRepository: RefreshTokenRepository, private val jwtUtil: JwtUtil, private val hunterRepository: HunterRepository) {
companion object {
private val log = LoggerFactory.getLogger(RefreshTokenService::class.java)
@@ -25,17 +26,18 @@ class RefreshTokenService(private val refreshTokenRepository: RefreshTokenReposi
removeToken(tokenId)
throw ExpiredRefreshTokenException(tokenId)
} else {
jwtUtil.generateToken(refreshToken.email)
val isAdmin = hunterRepository.findByEmail(refreshToken.email)?.isAdmin ?: false
jwtUtil.generateToken(refreshToken.email, isAdmin)
}
}?: throw InvalidRefreshTokenException(tokenId)
}
fun generateRefreshToken(email: String): RefreshId {
return refreshTokenRepository.save(RefreshTokenRecord(RefreshId.randomUUID(), email, LocalDateTime.now().plus(1, ChronoUnit.MONTHS))).token
return refreshTokenRepository.save(RefreshTokenRecord(RefreshId.randomUUID(), email, OffsetDateTime.now().plus(1, ChronoUnit.MONTHS))).token
}
fun isTokenExpired(token: RefreshTokenRecord): Boolean {
return token.expiryDateTime.isBefore(LocalDateTime.now())
return token.expiryDateTime.isBefore(OffsetDateTime.now())
}
fun getToken(token: RefreshId): RefreshTokenRecord? {

View File

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

View File

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

View File

@@ -2,9 +2,11 @@ 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
import net.halfbinary.scavengerhuntapi.model.domain.Hunter
import net.halfbinary.scavengerhuntapi.model.domain.Team
import net.halfbinary.scavengerhuntapi.model.domain.TeamHunt
import net.halfbinary.scavengerhuntapi.model.record.HunterTeamRecord
@@ -49,6 +51,16 @@ 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 getHuntersForTeam(huntId: HuntId, teamId: TeamId): List<Hunter> {
getTeamFromHunt(huntId, teamId)
val hunterIds = getHunterIdsForTeam(teamId)
return hunterRepository.findAllById(hunterIds).map { it.toDomain() }
}
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))