Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package hu.bme.sch.cmsch.component.kirpay

import com.fasterxml.jackson.annotation.JsonView
import hu.bme.sch.cmsch.dto.FullDetails
import hu.bme.sch.cmsch.model.RoleType
import hu.bme.sch.cmsch.util.getUserOrNull
import hu.bme.sch.cmsch.util.isAvailableForRole
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.security.core.Authentication
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController

@RestController
@RequestMapping("/api")
@ConditionalOnBean(KirPayComponent::class)
class KirPayApiController(
private val kirPayService: KirPayService,
private val kirPayComponent: KirPayComponent
) {

@JsonView(FullDetails::class)
@GetMapping("/kirpay-leaderboard")
fun leaderboard(auth: Authentication?): ResponseEntity<KirPayLeaderboardView> {
val user = auth?.getUserOrNull()

if (!kirPayComponent.leaderboardEnabled)
return ResponseEntity.ok(KirPayLeaderboardView())

if (!kirPayComponent.minRole.isAvailableForRole(user?.role ?: RoleType.GUEST))
return ResponseEntity.status(HttpStatus.FORBIDDEN).build()

var entries = kirPayService.getConsumptionLeaderboard()

val limit = kirPayComponent.leaderboardMaxEntries.toInt()
if (limit >= 0) {
entries = entries.take(limit)
}

return ResponseEntity.ok(KirPayLeaderboardView(entries = entries))
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package hu.bme.sch.cmsch.component.kirpay

import hu.bme.sch.cmsch.component.ComponentBase
import hu.bme.sch.cmsch.service.ControlPermissions
import hu.bme.sch.cmsch.setting.*
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty
import org.springframework.core.env.Environment
import org.springframework.stereotype.Service

@Service
@ConditionalOnBooleanProperty(value = ["hu.bme.sch.cmsch.component.load.kirpay"])
class KirPayComponent(
componentSettingService: ComponentSettingService,
env: Environment
) : ComponentBase(
componentSettingService,
"kirpay",
"/kirpay-leaderboard",
"Kir-Pay Toplista",

Check failure on line 19 in backend/src/main/kotlin/hu/bme/sch/cmsch/component/kirpay/KirPayComponent.kt

View check run for this annotation

SonarQubeCloud / [cmsch] SonarCloud Code Analysis

Define a constant instead of duplicating this literal "Kir-Pay Toplista" 3 times.

See more on https://sonarcloud.io/project/issues?id=kir-dev_cmsch-backend&issues=AZ8OsXoOZSTbxoM2pGVY&open=AZ8OsXoOZSTbxoM2pGVY&pullRequest=1045
ControlPermissions.PERMISSION_CONTROL_LEADERBOARD,
listOf(),
env
) {

val leaderboardGroup by SettingGroup(fieldName = "Toplista")

final var title by StringSettingRef("Kir-Pay Toplista",
fieldName = "Lap címe",
description = "Ez jelenik meg a böngésző címsorában")

final override var menuDisplayName by StringSettingRef("Kir-Pay Toplista", serverSideOnly = true,
fieldName = "Menü neve", description = "Ez lesz a neve a menünek")

final override var minRole by MinRoleSettingRef(setOf(),
fieldName = "Jogosultságok", description = "Melyik roleokkal nyitható meg az oldal")

/// -------------------------------------------------------------------------------------------------------------------

val connectionGroup by SettingGroup(fieldName = "Kapcsolat")

var kirPayBackendUrl by StringSettingRef("http://localhost:3001/v1/api/admin", serverSideOnly = true,
fieldName = "Kir-Pay backend URL", description = "A Kir-Pay backend admin API URL-je")

var kirPayBackendToken by StringSettingRef(serverSideOnly = true, fieldName = "Kir-Pay backend token",
description = "Basic auth token a Kir-Pay admin API-hoz")

/// -------------------------------------------------------------------------------------------------------------------

val displayGroup by SettingGroup(fieldName = "Kijelzés")

var leaderboardEnabled by BooleanSettingRef(true,
fieldName = "Toplista aktív", description = "A toplista leküldésre kerül")

var leaderboardMaxEntries by NumberSettingRef(50, fieldName = "Toplista sorainak száma",
description = "Hány felhasználót mutasson, -1 = az összeset")

var kirPayCurrency by StringSettingRef("JMF", fieldName = "A megjelenített Kir-Pay valuta")

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package hu.bme.sch.cmsch.component.kirpay

import com.fasterxml.jackson.annotation.JsonView
import hu.bme.sch.cmsch.dto.FullDetails

data class KirPayLeaderboardEntry(
@field:JsonView(FullDetails::class)
val name: String,

@field:JsonView(FullDetails::class)
val itemCount: Long
)

data class KirPayLeaderboardView(
@field:JsonView(FullDetails::class)
val entries: List<KirPayLeaderboardEntry> = listOf()
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package hu.bme.sch.cmsch.component.kirpay

import org.slf4j.LoggerFactory
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean
import org.springframework.http.client.reactive.ReactorClientHttpConnector
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Service
import org.springframework.web.reactive.function.client.WebClient
import org.springframework.web.reactive.function.client.WebClientResponseException
import org.springframework.web.reactive.function.client.bodyToMono
import reactor.netty.http.client.HttpClient
import java.time.Duration
import java.util.*

@Service
@ConditionalOnBean(KirPayComponent::class)
class KirPayService(
private val kirPayComponent: KirPayComponent,
webClientBuilder: WebClient.Builder
) {

private val log = LoggerFactory.getLogger(javaClass)

@Volatile
private var cachedLeaderboard: List<KirPayLeaderboardEntry>? = null
private val leaderboardLock = Any()

private val kirPayClient: WebClient by lazy {
val httpClient = HttpClient.create()
.responseTimeout(Duration.ofSeconds(15))
webClientBuilder
.clientConnector(ReactorClientHttpConnector(httpClient))
.baseUrl(kirPayComponent.kirPayBackendUrl)
.defaultHeader("Authorization", "Basic " + Base64.getEncoder().encodeToString(
kirPayComponent.kirPayBackendToken.toByteArray()
))
.build()
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

@Scheduled(fixedRate = 1000L * 60 * 60 * 10)
fun refreshCache() {
if (!kirPayComponent.leaderboardEnabled) return
val refreshed = fetchConsumptionLeaderboard()
synchronized(leaderboardLock) {
cachedLeaderboard = refreshed
}
log.info("Kir-Pay consumption leaderboard cache refreshed, {} entries", refreshed.size)
}

fun getConsumptionLeaderboard(): List<KirPayLeaderboardEntry> {
if (!kirPayComponent.leaderboardEnabled) return listOf()
cachedLeaderboard?.let { return it }
synchronized(leaderboardLock) {
cachedLeaderboard?.let { return it }
val result = fetchConsumptionLeaderboard()
cachedLeaderboard = result
return result
}
}

fun getBalanceByEmail(email: String?): Long? {
if (email.isNullOrBlank()) return null

return try {
val response = kirPayClient.get()
.uri("/accounts-by-email/{email}", email)
.retrieve()
.bodyToMono<Map<String, Any>>()
.block()

response?.get("balance")?.let { (it as Number).toLong() }
} catch (e: WebClientResponseException.NotFound) {
null
} catch (e: Exception) {
log.error("Failed to fetch Kir-Pay balance", e)
null
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

private fun fetchConsumptionLeaderboard(): List<KirPayLeaderboardEntry> {
return try {
val response = kirPayClient.get()
.uri("/consumption-leaderboard")
.retrieve()
.bodyToMono<List<Map<String, Any>>>()
.block()
?: listOf()

return response.map {
// Some gorgeous Kotlin code!
val name = it["name"] as? String ?: return@map null
val itemCount = it["itemCount"] as? Number ?: return@map null

KirPayLeaderboardEntry(name = name, itemCount = itemCount.toLong())
}.filterNotNull()

} catch (e: Exception) {
log.error("Failed to fetch Kir-Pay consumption leaderboard", e)
listOf()
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,15 @@ class ProfileComponent(
fieldName = "Helyzet lejárata", strictConversion = false,
description = "Ennyi ideig (másodpercben) jelenjen meg a helyzet az utolsó frissítés után")

/// -------------------------------------------------------------------------------------------------------------------
///

val kirPayGroup by SettingGroup(fieldName = "Kir-Pay",
description = "A Kir-Pay komponensnek is be kell kapcsolva lennie, hogy az itteni beállítások működjenek")

var showKirPayBalance by BooleanSettingRef(true,
fieldName = "Kir-Pay egyenleg látható", description = "Megjelenjen-e a Kir-Pay egyenleg a profilban")
Comment on lines +148 to +152

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

This creates a second independent showKirPayBalance switch.

ProfileService now gates the field on profileComponent.showKirPayBalance, but backend/src/main/kotlin/hu/bme/sch/cmsch/component/kirpay/KirPayService.kt, Lines 47-49 already gate the upstream call on kirPayComponent.showKirPayBalance. Turning on this new profile flag alone still returns no balance, so the feature now has two separate toggles for one behavior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@backend/src/main/kotlin/hu/bme/sch/cmsch/component/profile/ProfileComponent.kt`
around lines 148 - 152, The new profile-level showKirPayBalance setting in
ProfileComponent is duplicating an existing Kir-Pay feature toggle, so the
balance visibility is now controlled by two independent switches. Remove the
extra gate in ProfileService and rely on the existing
kirPayComponent.showKirPayBalance path used by KirPayService, or otherwise wire
both places to the same underlying setting so there is a single source of truth
for balance visibility. Refer to ProfileComponent, ProfileService, and
KirPayService when making the change.


/// -------------------------------------------------------------------------------------------------------------------

val tokenGoalGroup by SettingGroup(fieldName = "Token cél megjelenítése",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,9 @@ package hu.bme.sch.cmsch.component.profile

import hu.bme.sch.cmsch.component.admission.AdmissionService
import hu.bme.sch.cmsch.component.bmejegy.CheersBmejegyService
import hu.bme.sch.cmsch.component.bmejegy.LegacyBmejegyService
import hu.bme.sch.cmsch.component.debt.DebtDto
import hu.bme.sch.cmsch.component.debt.SoldProductRepository
import hu.bme.sch.cmsch.component.groupselection.GroupSelectionComponent
import hu.bme.sch.cmsch.component.kirpay.KirPayService
import hu.bme.sch.cmsch.component.location.LocationService
import hu.bme.sch.cmsch.component.login.CmschUser
import hu.bme.sch.cmsch.component.login.LoginComponent
Expand All @@ -32,6 +31,7 @@ import org.springframework.transaction.annotation.Isolation
import org.springframework.transaction.annotation.Transactional
import java.sql.SQLException
import java.util.*
import kotlin.jvm.optionals.getOrDefault

@Service
@ConditionalOnBean(ProfileComponent::class)
Expand All @@ -50,14 +50,15 @@ class ProfileService(
private val clock: TimeService,
private val startupPropertyConfig: StartupPropertyConfig,
private val admissionService: Optional<AdmissionService>,
private val cheersBmejegyService: CheersBmejegyService?
private val cheersBmejegyService: CheersBmejegyService?,
private val kirPayService: Optional<KirPayService>
) {

@Transactional(readOnly = true, isolation = Isolation.READ_COMMITTED)
fun getProfileForUser(user: UserEntity): ProfileView {
val group = user.group
val leavable = fetchWhetherGroupLeavable(group)
val tokenCategoryToDisplay = tokenComponent.map { it.collectRequiredType }.orElse(ALL_TOKEN_TYPE)
val tokenCategoryToDisplay = tokenComponent.map { it.collectRequiredType }.getOrDefault(ALL_TOKEN_TYPE)
val incompleteTasks = tasksService.map { it.getTasksThatNeedsToBeCompleted(user) }.orElse(null)

val raceStats: RaceStatsView? = raceService.map { it.getRaceStats(user) }.orElse(null)
Expand Down Expand Up @@ -117,16 +118,16 @@ class ProfileService(
// Debt controller
debts = fetchDebts(user).orElse(null),

// Leaderboard controller
leaderboard = null
// Kir-Pay balance
kirPayBalance = fetchKirPayBalance(user).orElse(null)
)
}

private fun mapQr(user: UserEntity): String? {
val canSeeQr = profileComponent.showQrMinRole.isAvailableForRole(user.role) && profileComponent.showQr

if (profileComponent.showQrOnlyIfTicketPresent && (canSeeQr || profileComponent.showProfilePicture)) {
return if (admissionService.map { it.hasTicket(user.cmschId) }.orElse(false)) user.cmschId else null
return if (admissionService.map { it.hasTicket(user.cmschId) }.getOrDefault(false)) user.cmschId else null
}

return if (canSeeQr || profileComponent.showProfilePicture) {
Expand Down Expand Up @@ -163,6 +164,7 @@ class ProfileService(
repo.getTokensForUserWithCategory(user, tokenCategoryToDisplay)
}
}

OwnershipType.GROUP -> if (group == null) Optional.of(0) else tokenService.map { repo ->
if (tokenCategoryToDisplay == ALL_TOKEN_TYPE) {
repo.countTokensForGroup(group)
Expand All @@ -175,7 +177,7 @@ class ProfileService(
private fun fetchFallbackGroup() =
profileComponent.selectionEnabled.mapIfTrue {
groupRepository
.findByName(loginComponent.map { it.fallbackGroupName }.orElse("Vendég"))
.findByName(loginComponent.map { it.fallbackGroupName }.getOrDefault("Vendég"))
.map { it.id }
.orElse(null)
}
Expand Down Expand Up @@ -229,7 +231,12 @@ class ProfileService(
.associate { Pair(it.id, it.name) }
}

@Retryable(value = [ SQLException::class ], maxRetries = 5, delay = 500L, multiplier = 1.5)
private fun fetchKirPayBalance(user: UserEntity) =
kirPayService
.filter { profileComponent.showKirPayBalance }
.map { it.getBalanceByEmail(user.email) }

@Retryable(value = [SQLException::class], maxRetries = 5, delay = 500L, multiplier = 1.5)
@Transactional(readOnly = false, isolation = Isolation.SERIALIZABLE)
fun changeAlias(user: UserEntity, newAlias: String): Boolean {
return if (newAlias.matches(Regex(profileComponent.aliasRegex))) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,12 +96,12 @@ data class ProfileView(
@field:JsonView(FullDetails::class)
val debts: List<DebtDto>? = null,

@field:JsonView(FullDetails::class)
val leaderboard: List<LeaderBoardEntry>? = null,

@field:JsonView(FullDetails::class)
val groupMessage: String? = null,

@field:JsonView(FullDetails::class)
val userMessage: String? = null,

@field:JsonView(FullDetails::class)
val kirPayBalance: Long? = null,
)
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ hu.bme.sch.cmsch.component.load.form=${LOAD_FORM:false}
hu.bme.sch.cmsch.component.load.groupselection=${LOAD_GROUPSELECTION:false}
hu.bme.sch.cmsch.component.load.home=${LOAD_HOME:false}
hu.bme.sch.cmsch.component.load.impressum=${LOAD_IMPRESSUM:true}
hu.bme.sch.cmsch.component.load.kirpay=${LOAD_KIRPAY:false}
hu.bme.sch.cmsch.component.load.leaderboard=${LOAD_LEADERBOARD:false}
hu.bme.sch.cmsch.component.load.location=${LOAD_LOCATION:false}
hu.bme.sch.cmsch.component.load.login=${LOAD_LOGIN:true}
Expand Down
Loading
Loading