From b4f72a318d2eda1b949bad19a48f9d3a31d9f0db Mon Sep 17 00:00:00 2001 From: aarbit Date: Fri, 15 May 2026 14:05:55 -0500 Subject: [PATCH] Adds team and hunter leaderboard endpoints --- README.md | 5 -- .../controller/StatsController.kt | 30 ++++++++++ .../converter/HunterLeaderboardConverter.kt | 10 ++++ .../converter/TeamLeaderboardConverter.kt | 10 ++++ .../model/domain/HunterLeaderboardEntry.kt | 7 +++ .../model/domain/TeamLeaderboardEntry.kt | 7 +++ .../response/HunterLeaderboardResponse.kt | 7 +++ .../model/response/TeamLeaderboardResponse.kt | 7 +++ .../repository/PhotoRepository.kt | 2 + .../scavengerhuntapi/service/StatsService.kt | 58 +++++++++++++++++++ 10 files changed, 138 insertions(+), 5 deletions(-) create mode 100644 src/main/kotlin/net/halfbinary/scavengerhuntapi/controller/StatsController.kt create mode 100644 src/main/kotlin/net/halfbinary/scavengerhuntapi/model/converter/HunterLeaderboardConverter.kt create mode 100644 src/main/kotlin/net/halfbinary/scavengerhuntapi/model/converter/TeamLeaderboardConverter.kt create mode 100644 src/main/kotlin/net/halfbinary/scavengerhuntapi/model/domain/HunterLeaderboardEntry.kt create mode 100644 src/main/kotlin/net/halfbinary/scavengerhuntapi/model/domain/TeamLeaderboardEntry.kt create mode 100644 src/main/kotlin/net/halfbinary/scavengerhuntapi/model/response/HunterLeaderboardResponse.kt create mode 100644 src/main/kotlin/net/halfbinary/scavengerhuntapi/model/response/TeamLeaderboardResponse.kt create mode 100644 src/main/kotlin/net/halfbinary/scavengerhuntapi/service/StatsService.kt diff --git a/README.md b/README.md index bb707ad..6b78b0c 100644 --- a/README.md +++ b/README.md @@ -40,8 +40,3 @@ All endpoints except `/signup` and `/login` require a JWT bearer token. 3. Include the access token in requests: `Authorization: Bearer ` 4. Refresh an expired token: `POST /refresh` 5. Log out: `POST /logout` - -## TODO - -- `GET /lead/hunt/{huntId}/team` — leaderboard: teams with scores for a hunt -- `GET /lead/hunt/{huntId}/hunter` — leaderboard: hunters with scores for a hunt diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/controller/StatsController.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/controller/StatsController.kt new file mode 100644 index 0000000..bd27009 --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/controller/StatsController.kt @@ -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> { + 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> { + return ResponseEntity.ok(statsService.getHunterLeaderboard(huntId).map { it.toResponse() }) + } +} diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/converter/HunterLeaderboardConverter.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/converter/HunterLeaderboardConverter.kt new file mode 100644 index 0000000..cd00f3a --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/converter/HunterLeaderboardConverter.kt @@ -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 +) \ No newline at end of file diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/converter/TeamLeaderboardConverter.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/converter/TeamLeaderboardConverter.kt new file mode 100644 index 0000000..7e397fe --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/converter/TeamLeaderboardConverter.kt @@ -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 +) \ No newline at end of file diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/domain/HunterLeaderboardEntry.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/domain/HunterLeaderboardEntry.kt new file mode 100644 index 0000000..32c15a9 --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/domain/HunterLeaderboardEntry.kt @@ -0,0 +1,7 @@ +package net.halfbinary.scavengerhuntapi.model.domain + +data class HunterLeaderboardEntry( + val rank: Int, + val hunterName: String, + val score: Int +) \ No newline at end of file diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/domain/TeamLeaderboardEntry.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/domain/TeamLeaderboardEntry.kt new file mode 100644 index 0000000..11b309e --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/domain/TeamLeaderboardEntry.kt @@ -0,0 +1,7 @@ +package net.halfbinary.scavengerhuntapi.model.domain + +data class TeamLeaderboardEntry( + val rank: Int, + val teamName: String, + val score: Int +) \ No newline at end of file diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/response/HunterLeaderboardResponse.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/response/HunterLeaderboardResponse.kt new file mode 100644 index 0000000..23ca6ed --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/response/HunterLeaderboardResponse.kt @@ -0,0 +1,7 @@ +package net.halfbinary.scavengerhuntapi.model.response + +data class HunterLeaderboardResponse( + val rank: Int, + val hunterName: String, + val score: Int +) \ No newline at end of file diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/response/TeamLeaderboardResponse.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/response/TeamLeaderboardResponse.kt new file mode 100644 index 0000000..3628692 --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/response/TeamLeaderboardResponse.kt @@ -0,0 +1,7 @@ +package net.halfbinary.scavengerhuntapi.model.response + +data class TeamLeaderboardResponse( + val rank: Int, + val teamName: String, + val score: Int +) diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/repository/PhotoRepository.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/repository/PhotoRepository.kt index 20384c5..df84d46 100644 --- a/src/main/kotlin/net/halfbinary/scavengerhuntapi/repository/PhotoRepository.kt +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/repository/PhotoRepository.kt @@ -3,6 +3,7 @@ package net.halfbinary.scavengerhuntapi.repository import net.halfbinary.scavengerhuntapi.model.HuntId import net.halfbinary.scavengerhuntapi.model.ItemId import net.halfbinary.scavengerhuntapi.model.PhotoId +import net.halfbinary.scavengerhuntapi.model.PhotoStatus import net.halfbinary.scavengerhuntapi.model.record.PhotoRecord import org.springframework.data.jpa.repository.JpaRepository import org.springframework.stereotype.Repository @@ -11,4 +12,5 @@ interface PhotoRepository : JpaRepository { fun findByItemId(itemId: ItemId): List fun findByHuntIdAndItemId(huntId: HuntId, itemId: ItemId): List fun findByIdAndItemIdAndHuntId(id: PhotoId, itemId: ItemId, huntId: HuntId): PhotoRecord? + fun findByHuntIdAndStatus(huntId: HuntId, status: PhotoStatus): List } \ No newline at end of file diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/service/StatsService.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/service/StatsService.kt new file mode 100644 index 0000000..54f2772 --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/service/StatsService.kt @@ -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 { + 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 { + 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) + } + } +}