Compare commits
25 Commits
1585b6eb7d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b5046f1db | |||
| 5e2976180c | |||
| 74391f8a46 | |||
| 4049dbbdaa | |||
| 8ff73cda2b | |||
| 08d0b1730a | |||
| 48b2ffd7b2 | |||
| 877e134166 | |||
| ec2bb1bcc6 | |||
| 6c3c94c5a3 | |||
| a34d2ddcf0 | |||
| b3801eb5e7 | |||
| 4dfdb54bb4 | |||
| 0a278530fb | |||
| 6288cc6249 | |||
| b4f72a318d | |||
| eed5a0dd56 | |||
| 6327e0d034 | |||
| ac6f3a7014 | |||
| dbd988a573 | |||
| 67fb801812 | |||
| bc1bcf6e8d | |||
| aff0872e38 | |||
| 63e015400b | |||
| b349380c93 |
6
.dockerignore
Normal file
6
.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
.git/
|
||||
.gradle/
|
||||
build/
|
||||
Claude Notes/
|
||||
*.md
|
||||
.gitignore
|
||||
15
.woodpecker.yaml
Normal file
15
.woodpecker.yaml
Normal file
@@ -0,0 +1,15 @@
|
||||
when:
|
||||
branch: main
|
||||
event:
|
||||
- push
|
||||
- manual
|
||||
steps:
|
||||
- name: build
|
||||
image: woodpeckerci/plugin-docker-buildx
|
||||
settings:
|
||||
repo: git.halfbinary.net/${CI_REPO_OWNER}/scavengerhunt-api
|
||||
registry: git.halfbinary.net
|
||||
tags: ${CI_PIPELINE_NUMBER}
|
||||
username: ${CI_REPO_OWNER}
|
||||
password:
|
||||
from_secret: docker_password
|
||||
14
Dockerfile
Normal file
14
Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
||||
FROM amazoncorretto:21-alpine-jdk AS build
|
||||
WORKDIR /app
|
||||
COPY gradlew .
|
||||
COPY gradle/ gradle/
|
||||
COPY build.gradle.kts settings.gradle.kts ./
|
||||
RUN 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"]
|
||||
50
README.md
50
README.md
@@ -2,17 +2,41 @@
|
||||
|
||||
REST API to support a community scavenger hunt app.
|
||||
|
||||
## Environment variables
|
||||
* `DB_PASSWORD` Password for the database
|
||||
* `DB_URL` JDBC URL for the database
|
||||
* `DB_USER` Username for the database
|
||||
* `JWT_SECRET` Secret pass for the JWT
|
||||
## Prerequisites
|
||||
|
||||
## TODO:
|
||||
### User Endpoints
|
||||
* upload photo for hunt item POST `/hunt/{huntId}/team/{teamId}/item/{itemId}/photo` - body: image binary
|
||||
* delete photo for hunt item DELETE `/hunt/{huntId}/team/{teamId}/item/{itemId}/photo/{photoId}`
|
||||
* list hunt teams with scores for hunt `GET /lead/hunt/{huntId}/team`
|
||||
* list hunters with scores for hunt GET `/lead/hunt/{huntId}/hunter`
|
||||
### Admin Endpoints
|
||||
* approve photo for hunt item POST `/admin/hunt/{huntId}/team/{teamId}/item/{itemId}/photo/{photoId}` - body: approval status
|
||||
- Java 21
|
||||
- MariaDB
|
||||
- MinIO
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
| --- | --- |
|
||||
| `DB_URL` | JDBC URL for the MariaDB database |
|
||||
| `DB_USER` | Database username |
|
||||
| `DB_PASSWORD` | Database password |
|
||||
| `JWT_SECRET` | Secret key used to sign JWTs |
|
||||
| `MINIO_ENDPOINT` | MinIO server URL (e.g. `http://localhost:9000`) |
|
||||
| `MINIO_ACCESS_KEY` | MinIO access key |
|
||||
| `MINIO_SECRET_KEY` | MinIO secret key |
|
||||
| `MINIO_BUCKET` | MinIO bucket name for photo storage |
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
./gradlew bootRun
|
||||
```
|
||||
|
||||
## API Documentation
|
||||
|
||||
Swagger UI is available at `/docs/swagger-ui.html` when the application is running.
|
||||
|
||||
## Authentication
|
||||
|
||||
All endpoints except `/signup` and `/login` require a JWT bearer token.
|
||||
|
||||
1. Create an account: `POST /signup`
|
||||
2. Log in: `POST /login` — returns an access token and a refresh token
|
||||
3. Include the access token in requests: `Authorization: Bearer <token>`
|
||||
4. Refresh an expired token: `POST /refresh`
|
||||
5. Log out: `POST /logout`
|
||||
|
||||
70
docker-compose.yml
Normal file
70
docker-compose.yml
Normal 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:
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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() })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package net.halfbinary.scavengerhuntapi.controller
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation
|
||||
import net.halfbinary.scavengerhuntapi.model.ImageVersion
|
||||
import net.halfbinary.scavengerhuntapi.model.PhotoId
|
||||
import net.halfbinary.scavengerhuntapi.service.PhotoService
|
||||
import org.springframework.core.io.InputStreamSource
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.security.core.Authentication
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RequestParam
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
@RestController
|
||||
@RequestMapping("photo")
|
||||
class PhotoController(private val photoService: PhotoService) {
|
||||
@GetMapping("/{photoId}/file")
|
||||
@Operation(summary = "Get the binary image information for the specified Photo")
|
||||
fun getPhoto(authentication: Authentication,
|
||||
@PathVariable photoId: PhotoId,
|
||||
@RequestParam(defaultValue = "LARGE") version: ImageVersion): ResponseEntity<InputStreamSource> {
|
||||
val photoFile = photoService.getPhotoFile(photoId, authentication.name, version)
|
||||
return ResponseEntity.ok()
|
||||
.contentType(photoFile.contentType)
|
||||
.body(photoFile.resource)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package net.halfbinary.scavengerhuntapi.controller
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation
|
||||
import net.halfbinary.scavengerhuntapi.model.HuntId
|
||||
import net.halfbinary.scavengerhuntapi.model.converter.toResponse
|
||||
import net.halfbinary.scavengerhuntapi.model.response.HunterLeaderboardResponse
|
||||
import net.halfbinary.scavengerhuntapi.model.response.TeamLeaderboardResponse
|
||||
import net.halfbinary.scavengerhuntapi.service.StatsService
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
@RestController
|
||||
@RequestMapping("stats/lead/hunt/{huntId}")
|
||||
class StatsController(private val statsService: StatsService) {
|
||||
|
||||
@GetMapping("/team")
|
||||
@Operation(summary = "Ranked teams with current total scores for a hunt")
|
||||
fun getTeamLeaderboard(@PathVariable huntId: HuntId): ResponseEntity<List<TeamLeaderboardResponse>> {
|
||||
return ResponseEntity.ok(statsService.getTeamLeaderboard(huntId).map { it.toResponse() })
|
||||
}
|
||||
|
||||
@GetMapping("/hunter")
|
||||
@Operation(summary = "Ranked hunters with current total scores for a hunt")
|
||||
fun getHunterLeaderboard(@PathVariable huntId: HuntId): ResponseEntity<List<HunterLeaderboardResponse>> {
|
||||
return ResponseEntity.ok(statsService.getHunterLeaderboard(huntId).map { it.toResponse() })
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package net.halfbinary.scavengerhuntapi.error
|
||||
|
||||
import net.halfbinary.scavengerhuntapi.error.exception.BadFileException
|
||||
import net.halfbinary.scavengerhuntapi.error.exception.ConflictException
|
||||
import net.halfbinary.scavengerhuntapi.error.exception.ForbiddenException
|
||||
import net.halfbinary.scavengerhuntapi.error.exception.InvalidEmailException
|
||||
import net.halfbinary.scavengerhuntapi.error.exception.LoginFailedException
|
||||
import net.halfbinary.scavengerhuntapi.error.exception.NotFoundException
|
||||
@@ -13,6 +15,7 @@ import org.springframework.web.bind.MethodArgumentNotValidException
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler
|
||||
import org.springframework.web.bind.annotation.ResponseStatus
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice
|
||||
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException
|
||||
import org.springframework.web.multipart.MaxUploadSizeExceededException
|
||||
import java.net.SocketTimeoutException
|
||||
|
||||
@@ -46,6 +49,18 @@ class ExceptionHandler {
|
||||
return e.message
|
||||
}
|
||||
|
||||
@ExceptionHandler(ForbiddenException::class)
|
||||
@ResponseStatus(HttpStatus.FORBIDDEN)
|
||||
fun forbiddenException(e: ForbiddenException): String? {
|
||||
return e.message
|
||||
}
|
||||
|
||||
@ExceptionHandler(ConflictException::class)
|
||||
@ResponseStatus(HttpStatus.CONFLICT)
|
||||
fun conflictException(e: ConflictException): String? {
|
||||
return e.message
|
||||
}
|
||||
|
||||
@ExceptionHandler(HttpMessageNotReadableException::class)
|
||||
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||
fun httpMessageNotReadableException(e: HttpMessageNotReadableException): Map<String, String?> {
|
||||
@@ -89,6 +104,12 @@ class ExceptionHandler {
|
||||
return "Unable to connect. Try again later."
|
||||
}
|
||||
|
||||
@ExceptionHandler(MethodArgumentTypeMismatchException::class)
|
||||
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||
fun argumentMismatchException(): String? {
|
||||
return "Invalid parameter value."
|
||||
}
|
||||
|
||||
private fun simpleMap(key: String, value: String?): Map<String, String?> {
|
||||
return mapOf(Pair(key, value))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
package net.halfbinary.scavengerhuntapi.error.exception
|
||||
|
||||
class ConflictException(override val message: String) : RuntimeException(message)
|
||||
@@ -0,0 +1,3 @@
|
||||
package net.halfbinary.scavengerhuntapi.error.exception
|
||||
|
||||
class ForbiddenException: RuntimeException("Access Denied.")
|
||||
@@ -4,6 +4,5 @@ enum class FoundStatus {
|
||||
NOT_FOUND,
|
||||
SUBMITTED,
|
||||
APPROVED,
|
||||
REJECTED,
|
||||
REMOVED
|
||||
REJECTED
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package net.halfbinary.scavengerhuntapi.model
|
||||
|
||||
enum class ImageVersion {
|
||||
ORIGINAL, LARGE, MEDIUM, SMALL
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package net.halfbinary.scavengerhuntapi.model.converter
|
||||
|
||||
import net.halfbinary.scavengerhuntapi.model.domain.HunterLeaderboardEntry
|
||||
import net.halfbinary.scavengerhuntapi.model.response.HunterLeaderboardResponse
|
||||
|
||||
fun HunterLeaderboardEntry.toResponse() = HunterLeaderboardResponse(
|
||||
rank = rank,
|
||||
hunterName = hunterName,
|
||||
score = score
|
||||
)
|
||||
@@ -15,6 +15,16 @@ fun Photo.toRecord() = PhotoRecord(
|
||||
statusChangeDateTime = statusChangeDateTime
|
||||
)
|
||||
|
||||
fun PhotoRecord.toDomain() = Photo(
|
||||
id = id,
|
||||
itemId = itemId,
|
||||
huntId = huntId,
|
||||
hunterId = hunterId,
|
||||
foundDateTime = foundDateTime,
|
||||
status = status,
|
||||
statusChangeDateTime = statusChangeDateTime
|
||||
)
|
||||
|
||||
fun Photo.toResponse(hunter: Hunter) = PhotoResponse(
|
||||
id = id,
|
||||
hunterName = hunter.name,
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package net.halfbinary.scavengerhuntapi.model.converter
|
||||
|
||||
import net.halfbinary.scavengerhuntapi.model.domain.TeamLeaderboardEntry
|
||||
import net.halfbinary.scavengerhuntapi.model.response.TeamLeaderboardResponse
|
||||
|
||||
fun TeamLeaderboardEntry.toResponse() = TeamLeaderboardResponse(
|
||||
rank = rank,
|
||||
teamName = teamName,
|
||||
score = score
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package net.halfbinary.scavengerhuntapi.model.domain
|
||||
|
||||
data class HunterLeaderboardEntry(
|
||||
val rank: Int,
|
||||
val hunterName: String,
|
||||
val score: Int
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package net.halfbinary.scavengerhuntapi.model.domain
|
||||
|
||||
import org.springframework.core.io.InputStreamResource
|
||||
import org.springframework.http.MediaType
|
||||
|
||||
data class PhotoFile(val resource: InputStreamResource, val contentType: MediaType)
|
||||
@@ -0,0 +1,7 @@
|
||||
package net.halfbinary.scavengerhuntapi.model.domain
|
||||
|
||||
data class TeamLeaderboardEntry(
|
||||
val rank: Int,
|
||||
val teamName: String,
|
||||
val score: Int
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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?
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
package net.halfbinary.scavengerhuntapi.model.request
|
||||
|
||||
data class ItemUpdateRequest(
|
||||
val name: String?,
|
||||
val points: Int?
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package net.halfbinary.scavengerhuntapi.model.response
|
||||
|
||||
data class HunterLeaderboardResponse(
|
||||
val rank: Int,
|
||||
val hunterName: String,
|
||||
val score: Int
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -2,11 +2,8 @@ package net.halfbinary.scavengerhuntapi.model.response
|
||||
|
||||
import net.halfbinary.scavengerhuntapi.model.FoundStatus
|
||||
import net.halfbinary.scavengerhuntapi.model.ItemId
|
||||
import java.time.LocalDateTime
|
||||
|
||||
data class TeamItemResponse(
|
||||
val id: ItemId,
|
||||
val hunterName: String?,
|
||||
val itemFoundStatus: FoundStatus,
|
||||
val itemStatusChangeDateTime: LocalDateTime,
|
||||
val itemFoundStatus: FoundStatus
|
||||
)
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package net.halfbinary.scavengerhuntapi.model.response
|
||||
|
||||
data class TeamLeaderboardResponse(
|
||||
val rank: Int,
|
||||
val teamName: String,
|
||||
val score: Int
|
||||
)
|
||||
@@ -1,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?
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package net.halfbinary.scavengerhuntapi.repository
|
||||
|
||||
import net.halfbinary.scavengerhuntapi.model.HunterId
|
||||
import net.halfbinary.scavengerhuntapi.model.TeamId
|
||||
import net.halfbinary.scavengerhuntapi.model.record.HunterTeamRecord
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.stereotype.Repository
|
||||
@@ -9,4 +10,5 @@ import java.util.*
|
||||
@Repository
|
||||
interface HunterTeamRepository : JpaRepository<HunterTeamRecord, UUID> {
|
||||
fun findByHunterId(hunterId: HunterId): List<HunterTeamRecord>
|
||||
fun findByTeamId(teamId: TeamId): List<HunterTeamRecord>
|
||||
}
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
package net.halfbinary.scavengerhuntapi.repository
|
||||
|
||||
import net.halfbinary.scavengerhuntapi.model.HuntId
|
||||
import net.halfbinary.scavengerhuntapi.model.ItemId
|
||||
import net.halfbinary.scavengerhuntapi.model.PhotoId
|
||||
import net.halfbinary.scavengerhuntapi.model.PhotoStatus
|
||||
import net.halfbinary.scavengerhuntapi.model.record.PhotoRecord
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.data.jpa.repository.Query
|
||||
import org.springframework.stereotype.Repository
|
||||
|
||||
@Repository
|
||||
interface PhotoRepository : JpaRepository<PhotoRecord, PhotoId> {
|
||||
@Query("""
|
||||
SELECT *
|
||||
FROM photo p
|
||||
WHERE
|
||||
p.
|
||||
""", nativeQuery = true)
|
||||
fun findPhotosByItemId(itemId: ItemId): List<PhotoRecord>
|
||||
fun findByItemId(itemId: ItemId): List<PhotoRecord>
|
||||
fun findByHuntIdAndItemId(huntId: HuntId, itemId: ItemId): List<PhotoRecord>
|
||||
fun findByIdAndItemIdAndHuntId(id: PhotoId, itemId: ItemId, huntId: HuntId): PhotoRecord?
|
||||
fun findByHuntIdAndStatus(huntId: HuntId, status: PhotoStatus): List<PhotoRecord>
|
||||
}
|
||||
@@ -1,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)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package net.halfbinary.scavengerhuntapi.service
|
||||
|
||||
import net.halfbinary.scavengerhuntapi.error.exception.NotFoundException
|
||||
import net.halfbinary.scavengerhuntapi.model.HunterId
|
||||
import net.halfbinary.scavengerhuntapi.model.converter.toDomain
|
||||
import net.halfbinary.scavengerhuntapi.model.domain.Hunter
|
||||
import net.halfbinary.scavengerhuntapi.repository.HunterRepository
|
||||
@@ -12,4 +13,8 @@ class HunterService(private val hunterRepository: HunterRepository) {
|
||||
return hunterRepository.findByEmail(email)?.toDomain()
|
||||
?: throw NotFoundException("No hunter with email $email found")
|
||||
}
|
||||
|
||||
fun getHunterById(hunterId: HunterId): Hunter {
|
||||
return hunterRepository.findById(hunterId).orElseThrow { NotFoundException("No hunter with id $hunterId found") }.toDomain()
|
||||
}
|
||||
}
|
||||
@@ -3,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))
|
||||
|
||||
@@ -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? {
|
||||
|
||||
@@ -4,7 +4,10 @@ import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Service
|
||||
import software.amazon.awssdk.core.sync.RequestBody
|
||||
import software.amazon.awssdk.services.s3.S3Client
|
||||
import software.amazon.awssdk.services.s3.model.GetObjectRequest
|
||||
import software.amazon.awssdk.services.s3.model.ListObjectsV2Request
|
||||
import software.amazon.awssdk.services.s3.model.PutObjectRequest
|
||||
import java.io.InputStream
|
||||
|
||||
@Service
|
||||
class S3StorageService(
|
||||
@@ -22,4 +25,25 @@ class S3StorageService(
|
||||
RequestBody.fromBytes(bytes)
|
||||
)
|
||||
}
|
||||
|
||||
fun findKeyByPrefix(prefix: String): String? {
|
||||
val response = s3Client.listObjectsV2(
|
||||
ListObjectsV2Request.builder()
|
||||
.bucket(bucket)
|
||||
.prefix(prefix)
|
||||
.maxKeys(1)
|
||||
.build()
|
||||
)
|
||||
return response.contents().firstOrNull()?.key()
|
||||
}
|
||||
|
||||
fun download(key: String): Pair<InputStream, String> {
|
||||
val response = s3Client.getObject(
|
||||
GetObjectRequest.builder()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.build()
|
||||
)
|
||||
return Pair(response, response.response().contentType())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package net.halfbinary.scavengerhuntapi.service
|
||||
|
||||
import net.halfbinary.scavengerhuntapi.model.HuntId
|
||||
import net.halfbinary.scavengerhuntapi.model.PhotoStatus
|
||||
import net.halfbinary.scavengerhuntapi.model.domain.HunterLeaderboardEntry
|
||||
import net.halfbinary.scavengerhuntapi.model.domain.TeamLeaderboardEntry
|
||||
import net.halfbinary.scavengerhuntapi.repository.ItemRepository
|
||||
import net.halfbinary.scavengerhuntapi.repository.PhotoRepository
|
||||
import org.springframework.stereotype.Service
|
||||
|
||||
@Service
|
||||
class StatsService(
|
||||
private val photoRepository: PhotoRepository,
|
||||
private val itemRepository: ItemRepository,
|
||||
private val teamService: TeamService,
|
||||
private val hunterService: HunterService
|
||||
) {
|
||||
fun getTeamLeaderboard(huntId: HuntId): List<TeamLeaderboardEntry> {
|
||||
val approvedPhotos = photoRepository.findByHuntIdAndStatus(huntId, PhotoStatus.APPROVED)
|
||||
val itemPoints = itemRepository.findAllByHuntId(huntId).associate { it.id to it.points }
|
||||
val teams = teamService.getListOfTeamsForHunt(huntId)
|
||||
|
||||
val sortedScores = teams.map { team ->
|
||||
val teamHunterIds = teamService.getHunterIdsForTeam(team.id)
|
||||
val score = approvedPhotos
|
||||
.filter { it.hunterId in teamHunterIds }
|
||||
.distinctBy { it.itemId }
|
||||
.sumOf { itemPoints[it.itemId] ?: 0 }
|
||||
team to score
|
||||
}.sortedByDescending { (_, score) -> score }
|
||||
|
||||
var rank = 1
|
||||
return sortedScores.mapIndexed { index, (team, score) ->
|
||||
if (index > 0 && sortedScores[index - 1].second != score) rank = index + 1
|
||||
TeamLeaderboardEntry(rank = rank, teamName = team.name, score = score)
|
||||
}
|
||||
}
|
||||
|
||||
fun getHunterLeaderboard(huntId: HuntId): List<HunterLeaderboardEntry> {
|
||||
val approvedPhotos = photoRepository.findByHuntIdAndStatus(huntId, PhotoStatus.APPROVED)
|
||||
val itemPoints = itemRepository.findAllByHuntId(huntId).associate { it.id to it.points }
|
||||
|
||||
val sortedScores = approvedPhotos
|
||||
.groupBy { it.hunterId }
|
||||
.map { (hunterId, photos) ->
|
||||
val score = photos.distinctBy { it.itemId }.sumOf { itemPoints[it.itemId] ?: 0 }
|
||||
hunterId to score
|
||||
}
|
||||
.sortedByDescending { (_, score) -> score }
|
||||
|
||||
var rank = 1
|
||||
return sortedScores.mapIndexed { index, (hunterId, score) ->
|
||||
if (index > 0 && sortedScores[index - 1].second != score) rank = index + 1
|
||||
val hunter = hunterService.getHunterById(hunterId)
|
||||
HunterLeaderboardEntry(rank = rank, hunterName = hunter.name, score = score)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,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))
|
||||
|
||||
Reference in New Issue
Block a user