23 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
51 changed files with 753 additions and 95 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. 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`

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 // Generate JWT token
fun generateToken(email: String): String { fun generateToken(email: String, isAdmin: Boolean): String {
return Jwts.builder() return Jwts.builder()
.subject(email) .subject(email)
.claim("isAdmin", isAdmin)
.issuedAt(Date()) .issuedAt(Date())
.expiration(Date(System.currentTimeMillis() + jwtExpirationMs)) .expiration(Date(System.currentTimeMillis() + jwtExpirationMs))
.signWith(key) .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.authentication.configuration.AuthenticationConfiguration
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity 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.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.CsrfConfigurer
import org.springframework.security.config.annotation.web.configurers.ExceptionHandlingConfigurer import org.springframework.security.config.annotation.web.configurers.ExceptionHandlingConfigurer
import org.springframework.security.config.annotation.web.configurers.SessionManagementConfigurer 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.crypto.password.PasswordEncoder
import org.springframework.security.web.SecurityFilterChain import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter 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 @Configuration
@@ -48,13 +51,25 @@ class SecurityConfig(private val authEntrypointJwt: AuthEntrypointJwt,
return BCryptPasswordEncoder() 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 @Bean
@Throws(Exception::class) @Throws(Exception::class)
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain? { fun securityFilterChain(http: HttpSecurity): SecurityFilterChain? {
// Updated configuration for Spring Security 6.x // Updated configuration for Spring Security 6.x
http http
.csrf { csrf: CsrfConfigurer<HttpSecurity> -> csrf.disable() } // Disable CSRF .csrf { csrf: CsrfConfigurer<HttpSecurity> -> csrf.disable() }
.cors { cors: CorsConfigurer<HttpSecurity> -> cors.disable() } // Disable CORS (or configure if needed) .cors { cors -> cors.configurationSource(corsConfigurationSource()) }
.exceptionHandling { exceptionHandling: ExceptionHandlingConfigurer<HttpSecurity> -> .exceptionHandling { exceptionHandling: ExceptionHandlingConfigurer<HttpSecurity> ->
exceptionHandling.authenticationEntryPoint( exceptionHandling.authenticationEntryPoint(
authEntrypointJwt authEntrypointJwt
@@ -67,7 +82,7 @@ class SecurityConfig(private val authEntrypointJwt: AuthEntrypointJwt,
} }
.authorizeHttpRequests { authorizeRequests -> .authorizeHttpRequests { authorizeRequests ->
authorizeRequests authorizeRequests
.requestMatchers("/auth/**", "/signup", "/docs/**") .requestMatchers("/auth/**", "/signup", "/docs/**", "/actuator/**")
.permitAll() .permitAll()
.anyRequest().authenticated() .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") @PostMapping("/login")
fun login(@Valid @RequestBody body: LoginRequest): ResponseEntity<LoginResponse> { fun login(@Valid @RequestBody body: LoginRequest): ResponseEntity<LoginResponse> {
val result = loginService.login(body.toDomain()) 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 refreshToken = refreshTokenService.generateRefreshToken(result.email)
val loginResponse = LoginResponse(accessToken, refreshToken) val loginResponse = LoginResponse(accessToken, refreshToken, result.name)
return ResponseEntity.ok(loginResponse) 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.converter.toResponse
import net.halfbinary.scavengerhuntapi.model.request.HuntCreateRequest import net.halfbinary.scavengerhuntapi.model.request.HuntCreateRequest
import net.halfbinary.scavengerhuntapi.model.request.HuntStatus 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.model.response.HuntResponse
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.security.access.prepost.PreAuthorize
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
@@ -44,6 +46,12 @@ class HuntController(private val huntService: HuntService) {
return ResponseEntity.ok(huntService.getAllHunts(HuntStatus.UNSTARTED).map { it.toResponse() }) 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')") @PreAuthorize("hasRole('ADMIN')")
@Tag(name = "Admin") @Tag(name = "Admin")
@PostMapping @PostMapping
@@ -52,6 +60,14 @@ class HuntController(private val huntService: HuntService) {
return ResponseEntity.ok(huntService.createHunt(huntRequest.toDomain()).toResponse()) 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')") @PreAuthorize("hasRole('ADMIN')")
@Tag(name = "Admin") @Tag(name = "Admin")
@GetMapping("/hunter/{hunterId}") @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.PathVariable
import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
@RestController @RestController
@@ -27,13 +26,13 @@ class HunterController(private val hunterService: HunterService,
@GetMapping("/hunt/ongoing") @GetMapping("/hunt/ongoing")
@Operation(summary = "Gets list of all currently running Hunts (filtered by the calling hunter)") @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 email = authentication.name
val isAdmin = hunterService.getHunterByEmail(email).isAdmin val isAdmin = hunterService.getHunterByEmail(email).isAdmin
return if(isAdmin) { return if(isAdmin) {
ResponseEntity.ok(huntService.getAllHunts(HuntStatus.ONGOING).map { it.toResponse() }) ResponseEntity.ok(huntService.getAllHunts(HuntStatus.ONGOING).map { it.toResponse() })
} else { } 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.toDomain
import net.halfbinary.scavengerhuntapi.model.converter.toResponse 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.request.ItemUpdateRequest
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.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.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
@@ -24,13 +28,13 @@ import org.springframework.web.bind.annotation.RestController
class ItemController(private val huntService: HuntService) { class ItemController(private val huntService: HuntService) {
@GetMapping @GetMapping
fun getItemsForHunt(@PathVariable huntId: HuntId): ResponseEntity<List<ItemResponse>> { fun getItemsForHunt(@PathVariable huntId: HuntId, authentication: Authentication): ResponseEntity<List<ItemResponse>> {
return ResponseEntity.ok(huntService.getItemsForHunt(huntId).map { it.toResponse() }) return ResponseEntity.ok(huntService.getItemsForHunt(huntId, authentication.name).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')") @PreAuthorize("hasRole('ADMIN')")
@@ -41,4 +45,21 @@ class ItemController(private val huntService: HuntService) {
return ResponseEntity.ok(huntService.addItemToHunt(huntId, body.toDomain()).toResponse()) 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.PhotoId
import net.halfbinary.scavengerhuntapi.model.TeamId import net.halfbinary.scavengerhuntapi.model.TeamId
import net.halfbinary.scavengerhuntapi.model.converter.toResponse 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.request.TeamRequest
import net.halfbinary.scavengerhuntapi.model.response.HunterSummaryResponse
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.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
@@ -47,21 +49,39 @@ class TeamController(private val teamService: TeamService, private val photoServ
return ResponseEntity.ok(teamService.getTeamFromHunt(huntId, teamId).toResponse()) 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}") @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, 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 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") @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}")
@@ -74,15 +94,6 @@ class TeamController(private val teamService: TeamService, private val photoServ
return ResponseEntity.ok(photoService.getPhotoInfo(huntId, teamId, itemId, photoId, authentication.name)) return ResponseEntity.ok(photoService.getPhotoInfo(huntId, teamId, itemId, photoId, authentication.name))
} }
@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")
}
@PostMapping("/{teamId}/item/{itemId}/photo") @PostMapping("/{teamId}/item/{itemId}/photo")
@Operation(summary = "Save photo information and store the binary file") @Operation(summary = "Save photo information and store the binary file")
fun submitPhoto(@PathVariable huntId: HuntId, fun submitPhoto(@PathVariable huntId: HuntId,

View File

@@ -1,6 +1,7 @@
package net.halfbinary.scavengerhuntapi.error package net.halfbinary.scavengerhuntapi.error
import net.halfbinary.scavengerhuntapi.error.exception.BadFileException 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.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
@@ -14,6 +15,7 @@ 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 org.springframework.web.multipart.MaxUploadSizeExceededException
import java.net.SocketTimeoutException import java.net.SocketTimeoutException
@@ -53,6 +55,12 @@ class ExceptionHandler {
return e.message 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?> {
@@ -96,6 +104,12 @@ class ExceptionHandler {
return "Unable to connect. Try again later." 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 ConflictException(override val message: String) : RuntimeException(message)

View File

@@ -1,3 +1,3 @@
package net.halfbinary.scavengerhuntapi.error.exception package net.halfbinary.scavengerhuntapi.error.exception
class ForbiddenException(override val message: String): RuntimeException(message) 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

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

@@ -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 package net.halfbinary.scavengerhuntapi.model.domain
import net.halfbinary.scavengerhuntapi.model.HuntId import net.halfbinary.scavengerhuntapi.model.HuntId
import java.time.LocalDateTime import java.time.OffsetDateTime
import java.util.* import java.util.*
data class Hunt( data class Hunt(
val id: HuntId = UUID.randomUUID(), val id: HuntId = UUID.randomUUID(),
val title: String, val title: String,
val startDateTime: LocalDateTime, val startDateTime: OffsetDateTime,
val endDateTime: LocalDateTime, val endDateTime: OffsetDateTime,
val isTerminated: Boolean 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.ItemId
import net.halfbinary.scavengerhuntapi.model.PhotoId import net.halfbinary.scavengerhuntapi.model.PhotoId
import net.halfbinary.scavengerhuntapi.model.PhotoStatus import net.halfbinary.scavengerhuntapi.model.PhotoStatus
import java.time.LocalDateTime import java.time.OffsetDateTime
import java.util.UUID import java.util.*
data class Photo( data class Photo(
val id: PhotoId = UUID.randomUUID(), val id: PhotoId = UUID.randomUUID(),
val itemId: ItemId, val itemId: ItemId,
val huntId: HuntId, val huntId: HuntId,
val hunterId: HunterId, val hunterId: HunterId,
val foundDateTime: LocalDateTime, val foundDateTime: OffsetDateTime,
val status: PhotoStatus, 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.Id
import jakarta.persistence.Table import jakarta.persistence.Table
import net.halfbinary.scavengerhuntapi.model.HuntId import net.halfbinary.scavengerhuntapi.model.HuntId
import java.time.LocalDateTime import java.time.OffsetDateTime
/** /**
* Represents a scavenger hunt event * Represents a scavenger hunt event
@@ -16,7 +16,7 @@ data class HuntRecord(
@Id @Id
val id: HuntId, val id: HuntId,
val title: String, val title: String,
val startDateTime: LocalDateTime, val startDateTime: OffsetDateTime,
val endDateTime: LocalDateTime, val endDateTime: OffsetDateTime,
val isTerminated: Boolean 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.ItemId
import net.halfbinary.scavengerhuntapi.model.PhotoId import net.halfbinary.scavengerhuntapi.model.PhotoId
import net.halfbinary.scavengerhuntapi.model.PhotoStatus import net.halfbinary.scavengerhuntapi.model.PhotoStatus
import java.time.LocalDateTime import java.time.OffsetDateTime
/** /**
* Represents a found Item for a Hunt by a Hunter * Represents a found Item for a Hunt by a Hunter
@@ -21,7 +21,7 @@ data class PhotoRecord(
val itemId: ItemId, val itemId: ItemId,
val huntId: HuntId, val huntId: HuntId,
val hunterId: HunterId, val hunterId: HunterId,
val foundDateTime: LocalDateTime, val foundDateTime: OffsetDateTime,
val status: PhotoStatus, 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.Id
import jakarta.persistence.Table import jakarta.persistence.Table
import net.halfbinary.scavengerhuntapi.model.RefreshId import net.halfbinary.scavengerhuntapi.model.RefreshId
import java.time.LocalDateTime import java.time.OffsetDateTime
@Entity @Entity
@Table(name = "refresh_token") @Table(name = "refresh_token")
@@ -12,5 +12,5 @@ data class RefreshTokenRecord(
@Id @Id
val token: RefreshId, val token: RefreshId,
val email: String, 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.Future
import jakarta.validation.constraints.NotBlank import jakarta.validation.constraints.NotBlank
import java.time.LocalDateTime import java.time.OffsetDateTime
data class HuntCreateRequest( data class HuntCreateRequest(
@field:NotBlank(message = "Hunt title is required") @field:NotBlank(message = "Hunt title is required")
val title: String, val title: String,
@field:Future @field:Future
val startDateTime: LocalDateTime, val startDateTime: OffsetDateTime,
@field:Future @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 package net.halfbinary.scavengerhuntapi.model.response
import net.halfbinary.scavengerhuntapi.model.HuntId import net.halfbinary.scavengerhuntapi.model.HuntId
import java.time.LocalDateTime import java.time.OffsetDateTime
data class HuntResponse( data class HuntResponse(
val id: HuntId, val id: HuntId,
val title: String, val title: String,
val startDateTime: LocalDateTime, val startDateTime: OffsetDateTime,
val endDateTime: LocalDateTime, val endDateTime: OffsetDateTime,
val isTerminated: Boolean 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( data class LoginResponse(
val accessToken: String, 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.PhotoId
import net.halfbinary.scavengerhuntapi.model.PhotoStatus import net.halfbinary.scavengerhuntapi.model.PhotoStatus
import java.time.LocalDateTime import java.time.OffsetDateTime
data class PhotoResponse( data class PhotoResponse(
val id: PhotoId, val id: PhotoId,
val hunterName: String, val hunterName: String,
val photoUploadDateTime: LocalDateTime, val photoUploadDateTime: OffsetDateTime,
val photoStatus: PhotoStatus, 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.FoundStatus
import net.halfbinary.scavengerhuntapi.model.ItemId import net.halfbinary.scavengerhuntapi.model.ItemId
import java.time.LocalDateTime
data class TeamItemResponse( data class TeamItemResponse(
val id: ItemId, val id: ItemId,
val hunterName: String?, val itemFoundStatus: FoundStatus
val itemFoundStatus: FoundStatus,
val itemStatusChangeDateTime: LocalDateTime,
) )

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 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 net.halfbinary.scavengerhuntapi.model.record.HuntItemRecord
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 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 package net.halfbinary.scavengerhuntapi.repository
import net.halfbinary.scavengerhuntapi.model.HunterId 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
@@ -9,4 +10,5 @@ import java.util.*
@Repository @Repository
interface HunterTeamRepository : JpaRepository<HunterTeamRecord, UUID> { interface HunterTeamRepository : JpaRepository<HunterTeamRecord, UUID> {
fun findByHunterId(hunterId: HunterId): List<HunterTeamRecord> fun findByHunterId(hunterId: HunterId): List<HunterTeamRecord>
fun findByTeamId(teamId: TeamId): List<HunterTeamRecord>
} }

View File

@@ -3,11 +3,14 @@ package net.halfbinary.scavengerhuntapi.repository
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.PhotoId import net.halfbinary.scavengerhuntapi.model.PhotoId
import net.halfbinary.scavengerhuntapi.model.PhotoStatus
import net.halfbinary.scavengerhuntapi.model.record.PhotoRecord import net.halfbinary.scavengerhuntapi.model.record.PhotoRecord
import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository import org.springframework.stereotype.Repository
@Repository @Repository
interface PhotoRepository : JpaRepository<PhotoRecord, PhotoId> { interface PhotoRepository : JpaRepository<PhotoRecord, PhotoId> {
fun findByItemId(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 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 package net.halfbinary.scavengerhuntapi.service
import net.halfbinary.scavengerhuntapi.error.exception.ForbiddenException
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.HunterId
import net.halfbinary.scavengerhuntapi.model.ItemId
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.HuntItem
import net.halfbinary.scavengerhuntapi.model.domain.Item 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.model.request.HuntUpdateRequest
import net.halfbinary.scavengerhuntapi.model.request.ItemUpdateRequest
import net.halfbinary.scavengerhuntapi.repository.HuntItemRepository 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 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.OffsetDateTime
@Service @Service
class HuntService( class HuntService(
private val huntRepository: HuntRepository, private val huntRepository: HuntRepository,
private val itemRepository: ItemRepository, private val itemRepository: ItemRepository,
private val huntItemRepository: HuntItemRepository private val huntItemRepository: HuntItemRepository,
private val hunterService: HunterService
) { ) {
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")
@@ -44,16 +49,16 @@ class HuntService(
val filteredHunts = when (status) { val filteredHunts = when (status) {
HuntStatus.ONGOING -> { HuntStatus.ONGOING -> {
allHunts allHunts
.filter { !it.isTerminated && it.startDateTime < LocalDateTime.now() && it.endDateTime > LocalDateTime.now() } .filter { !it.isTerminated && it.startDateTime < OffsetDateTime.now() && it.endDateTime > OffsetDateTime.now() }
.toList() .toList()
} }
HuntStatus.CLOSED -> { HuntStatus.CLOSED -> {
allHunts allHunts
.filter { it.isTerminated || it.endDateTime < LocalDateTime.now() } .filter { it.isTerminated || it.endDateTime < OffsetDateTime.now() }
} }
HuntStatus.UNSTARTED -> { HuntStatus.UNSTARTED -> {
allHunts allHunts
.filter { !it.isTerminated && it.startDateTime > LocalDateTime.now() } .filter { !it.isTerminated && it.startDateTime > OffsetDateTime.now() }
} }
else -> { allHunts } else -> { allHunts }
} }
@@ -64,8 +69,22 @@ class HuntService(
return huntRepository.save(hunt.toRecord()).toDomain() return huntRepository.save(hunt.toRecord()).toDomain()
} }
fun getItemsForHunt(huntId: HuntId): List<Item> { fun updateHunt(huntId: HuntId, request: HuntUpdateRequest): Hunt {
huntRepository.findByIdOrNull(huntId) ?: throw NotFoundException("No hunt with id $huntId found") 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() } return itemRepository.findAllByHuntId(huntId).map { it.toDomain() }
} }
@@ -75,4 +94,23 @@ class HuntService(
huntItemRepository.save(HuntItem(huntId = huntId, itemId = savedItem.id).toRecord()) huntItemRepository.save(HuntItem(huntId = huntId, itemId = savedItem.id).toRecord())
return savedItem 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

@@ -3,9 +3,12 @@ package net.halfbinary.scavengerhuntapi.service
import net.coobird.thumbnailator.Thumbnails import net.coobird.thumbnailator.Thumbnails
import net.coobird.thumbnailator.tasks.UnsupportedFormatException import net.coobird.thumbnailator.tasks.UnsupportedFormatException
import net.halfbinary.scavengerhuntapi.error.exception.BadFileException 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.ForbiddenException
import net.halfbinary.scavengerhuntapi.error.exception.NotFoundException import net.halfbinary.scavengerhuntapi.error.exception.NotFoundException
import net.halfbinary.scavengerhuntapi.model.FoundStatus
import net.halfbinary.scavengerhuntapi.model.HuntId import net.halfbinary.scavengerhuntapi.model.HuntId
import net.halfbinary.scavengerhuntapi.model.ImageVersion
import net.halfbinary.scavengerhuntapi.model.ItemId import net.halfbinary.scavengerhuntapi.model.ItemId
import net.halfbinary.scavengerhuntapi.model.PhotoId import net.halfbinary.scavengerhuntapi.model.PhotoId
import net.halfbinary.scavengerhuntapi.model.PhotoStatus import net.halfbinary.scavengerhuntapi.model.PhotoStatus
@@ -14,24 +17,34 @@ 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.converter.toResponse import net.halfbinary.scavengerhuntapi.model.converter.toResponse
import net.halfbinary.scavengerhuntapi.model.domain.Photo 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.model.response.PhotoResponse
import net.halfbinary.scavengerhuntapi.repository.PhotoRepository 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.http.MediaType
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.web.multipart.MultipartFile import org.springframework.web.multipart.MultipartFile
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.time.LocalDateTime import java.time.OffsetDateTime
private const val PHOTO_NOT_FOUND = "Photo not found"
@Service @Service
class PhotoService( class PhotoService(
private val photoRepository: PhotoRepository, private val photoRepository: PhotoRepository,
private val hunterService: HunterService, private val hunterService: HunterService,
private val teamService: TeamService, private val teamService: TeamService,
private val huntService: HuntService,
private val s3StorageService: S3StorageService, private val s3StorageService: S3StorageService,
private val fileProbeService: FileProbeService private val fileProbeService: FileProbeService
) { ) {
fun submitPhoto(huntId: HuntId, itemId: ItemId, email: String, file: MultipartFile) { 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 originalBytes = file.bytes
val fileType = fileProbeService.getFileType(originalBytes) val fileType = fileProbeService.getFileType(originalBytes)
@@ -43,8 +56,7 @@ class PhotoService(
throw BadFileException("Image type is not supported") throw BadFileException("Image type is not supported")
} }
val hunter = hunterService.getHunterByEmail(email) val now = OffsetDateTime.now()
val now = LocalDateTime.now()
val photo = Photo( val photo = Photo(
itemId = itemId, itemId = itemId,
huntId = huntId, huntId = huntId,
@@ -56,7 +68,7 @@ class PhotoService(
val savedRecord = photoRepository.save(photo.toRecord()) val savedRecord = photoRepository.save(photo.toRecord())
val baseName = savedRecord.id.toString() 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}_large.jpg", originalAsJpeg, MediaType.IMAGE_JPEG_VALUE)
s3StorageService.upload("${baseName}_medium.jpg", resize(originalBytes, 800), 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) s3StorageService.upload("${baseName}_small.jpg", resize(originalBytes, 200), MediaType.IMAGE_JPEG_VALUE)
@@ -65,17 +77,129 @@ class PhotoService(
fun getPhotoInfo(huntId: HuntId, teamId: TeamId, itemId: ItemId, photoId: PhotoId, email: String): PhotoResponse { fun getPhotoInfo(huntId: HuntId, teamId: TeamId, itemId: ItemId, photoId: PhotoId, email: String): PhotoResponse {
val requestingHunter = hunterService.getHunterByEmail(email) val requestingHunter = hunterService.getHunterByEmail(email)
val photoRecord = photoRepository.findByIdAndItemIdAndHuntId(photoId, itemId, huntId) val photoRecord = photoRepository.findByIdAndItemIdAndHuntId(photoId, itemId, huntId)
?: throw NotFoundException("Photo not found") ?: throw NotFoundException(PHOTO_NOT_FOUND)
if (!requestingHunter.isAdmin) { if (!requestingHunter.isAdmin) {
val team = teamService.getTeamForHunterInHunt(huntId, email) val hunt = huntService.getHunt(huntId)
if (team.id != teamId) throw ForbiddenException("Access denied") 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) val submitter = hunterService.getHunterById(photoRecord.hunterId)
return photoRecord.toDomain().toResponse(submitter) 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 { private fun toJpeg(bytes: ByteArray): ByteArray {
val output = ByteArrayOutputStream() val output = ByteArrayOutputStream()
Thumbnails.of(ByteArrayInputStream(bytes)) 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.error.exception.InvalidRefreshTokenException
import net.halfbinary.scavengerhuntapi.model.RefreshId import net.halfbinary.scavengerhuntapi.model.RefreshId
import net.halfbinary.scavengerhuntapi.model.record.RefreshTokenRecord import net.halfbinary.scavengerhuntapi.model.record.RefreshTokenRecord
import net.halfbinary.scavengerhuntapi.repository.HunterRepository
import net.halfbinary.scavengerhuntapi.repository.RefreshTokenRepository import net.halfbinary.scavengerhuntapi.repository.RefreshTokenRepository
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
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.OffsetDateTime
import java.time.temporal.ChronoUnit import java.time.temporal.ChronoUnit
@Service @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 { companion object {
private val log = LoggerFactory.getLogger(RefreshTokenService::class.java) private val log = LoggerFactory.getLogger(RefreshTokenService::class.java)
@@ -25,17 +26,18 @@ class RefreshTokenService(private val refreshTokenRepository: RefreshTokenReposi
removeToken(tokenId) removeToken(tokenId)
throw ExpiredRefreshTokenException(tokenId) throw ExpiredRefreshTokenException(tokenId)
} else { } else {
jwtUtil.generateToken(refreshToken.email) val isAdmin = hunterRepository.findByEmail(refreshToken.email)?.isAdmin ?: false
jwtUtil.generateToken(refreshToken.email, isAdmin)
} }
}?: throw InvalidRefreshTokenException(tokenId) }?: throw InvalidRefreshTokenException(tokenId)
} }
fun generateRefreshToken(email: String): RefreshId { 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 { fun isTokenExpired(token: RefreshTokenRecord): Boolean {
return token.expiryDateTime.isBefore(LocalDateTime.now()) return token.expiryDateTime.isBefore(OffsetDateTime.now())
} }
fun getToken(token: RefreshId): RefreshTokenRecord? { fun getToken(token: RefreshId): RefreshTokenRecord? {

View File

@@ -4,7 +4,10 @@ import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import software.amazon.awssdk.core.sync.RequestBody import software.amazon.awssdk.core.sync.RequestBody
import software.amazon.awssdk.services.s3.S3Client 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 software.amazon.awssdk.services.s3.model.PutObjectRequest
import java.io.InputStream
@Service @Service
class S3StorageService( class S3StorageService(
@@ -22,4 +25,25 @@ class S3StorageService(
RequestBody.fromBytes(bytes) 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.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
import net.halfbinary.scavengerhuntapi.model.domain.Hunter
import net.halfbinary.scavengerhuntapi.model.domain.Team import net.halfbinary.scavengerhuntapi.model.domain.Team
import net.halfbinary.scavengerhuntapi.model.domain.TeamHunt import net.halfbinary.scavengerhuntapi.model.domain.TeamHunt
import net.halfbinary.scavengerhuntapi.model.record.HunterTeamRecord import net.halfbinary.scavengerhuntapi.model.record.HunterTeamRecord
@@ -49,6 +51,16 @@ class TeamService(
?: throw NotFoundException("No team found for hunter $email in hunt $huntId") ?: 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) { 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))