Ability for visitors to add users to queue and processing with live progress

This commit is contained in:
nise.moe 2024-02-22 04:39:23 +01:00
parent 45b4334011
commit 63be57dfc8
17 changed files with 338 additions and 53 deletions

View File

@ -17,7 +17,7 @@ import org.jooq.Identity
import org.jooq.Name import org.jooq.Name
import org.jooq.Record import org.jooq.Record
import org.jooq.Records import org.jooq.Records
import org.jooq.Row5 import org.jooq.Row7
import org.jooq.Schema import org.jooq.Schema
import org.jooq.SelectField import org.jooq.SelectField
import org.jooq.Table import org.jooq.Table
@ -88,6 +88,16 @@ open class UpdateUserQueue(
*/ */
val PROCESSED_AT: TableField<UpdateUserQueueRecord, LocalDateTime?> = createField(DSL.name("processed_at"), SQLDataType.LOCALDATETIME(6), this, "") val PROCESSED_AT: TableField<UpdateUserQueueRecord, LocalDateTime?> = createField(DSL.name("processed_at"), SQLDataType.LOCALDATETIME(6), this, "")
/**
* The column <code>public.update_user_queue.progress_current</code>.
*/
val PROGRESS_CURRENT: TableField<UpdateUserQueueRecord, Int?> = createField(DSL.name("progress_current"), SQLDataType.INTEGER, this, "")
/**
* The column <code>public.update_user_queue.progress_total</code>.
*/
val PROGRESS_TOTAL: TableField<UpdateUserQueueRecord, Int?> = createField(DSL.name("progress_total"), SQLDataType.INTEGER, this, "")
private constructor(alias: Name, aliased: Table<UpdateUserQueueRecord>?): this(alias, null, null, aliased, null) private constructor(alias: Name, aliased: Table<UpdateUserQueueRecord>?): this(alias, null, null, aliased, null)
private constructor(alias: Name, aliased: Table<UpdateUserQueueRecord>?, parameters: Array<Field<*>?>?): this(alias, null, null, aliased, parameters) private constructor(alias: Name, aliased: Table<UpdateUserQueueRecord>?, parameters: Array<Field<*>?>?): this(alias, null, null, aliased, parameters)
@ -130,18 +140,18 @@ open class UpdateUserQueue(
override fun rename(name: Table<*>): UpdateUserQueue = UpdateUserQueue(name.getQualifiedName(), null) override fun rename(name: Table<*>): UpdateUserQueue = UpdateUserQueue(name.getQualifiedName(), null)
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Row5 type methods // Row7 type methods
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
override fun fieldsRow(): Row5<Int?, Long?, Boolean?, LocalDateTime?, LocalDateTime?> = super.fieldsRow() as Row5<Int?, Long?, Boolean?, LocalDateTime?, LocalDateTime?> override fun fieldsRow(): Row7<Int?, Long?, Boolean?, LocalDateTime?, LocalDateTime?, Int?, Int?> = super.fieldsRow() as Row7<Int?, Long?, Boolean?, LocalDateTime?, LocalDateTime?, Int?, Int?>
/** /**
* Convenience mapping calling {@link SelectField#convertFrom(Function)}. * Convenience mapping calling {@link SelectField#convertFrom(Function)}.
*/ */
fun <U> mapping(from: (Int?, Long?, Boolean?, LocalDateTime?, LocalDateTime?) -> U): SelectField<U> = convertFrom(Records.mapping(from)) fun <U> mapping(from: (Int?, Long?, Boolean?, LocalDateTime?, LocalDateTime?, Int?, Int?) -> U): SelectField<U> = convertFrom(Records.mapping(from))
/** /**
* Convenience mapping calling {@link SelectField#convertFrom(Class, * Convenience mapping calling {@link SelectField#convertFrom(Class,
* Function)}. * Function)}.
*/ */
fun <U> mapping(toType: Class<U>, from: (Int?, Long?, Boolean?, LocalDateTime?, LocalDateTime?) -> U): SelectField<U> = convertFrom(toType, Records.mapping(from)) fun <U> mapping(toType: Class<U>, from: (Int?, Long?, Boolean?, LocalDateTime?, LocalDateTime?, Int?, Int?) -> U): SelectField<U> = convertFrom(toType, Records.mapping(from))
} }

View File

@ -10,8 +10,8 @@ import java.time.LocalDateTime
import org.jooq.Field import org.jooq.Field
import org.jooq.Record1 import org.jooq.Record1
import org.jooq.Record5 import org.jooq.Record7
import org.jooq.Row5 import org.jooq.Row7
import org.jooq.impl.UpdatableRecordImpl import org.jooq.impl.UpdatableRecordImpl
@ -19,7 +19,7 @@ import org.jooq.impl.UpdatableRecordImpl
* This class is generated by jOOQ. * This class is generated by jOOQ.
*/ */
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
open class UpdateUserQueueRecord private constructor() : UpdatableRecordImpl<UpdateUserQueueRecord>(UpdateUserQueue.UPDATE_USER_QUEUE), Record5<Int?, Long?, Boolean?, LocalDateTime?, LocalDateTime?> { open class UpdateUserQueueRecord private constructor() : UpdatableRecordImpl<UpdateUserQueueRecord>(UpdateUserQueue.UPDATE_USER_QUEUE), Record7<Int?, Long?, Boolean?, LocalDateTime?, LocalDateTime?, Int?, Int?> {
open var id: Int? open var id: Int?
set(value): Unit = set(0, value) set(value): Unit = set(0, value)
@ -41,6 +41,14 @@ open class UpdateUserQueueRecord private constructor() : UpdatableRecordImpl<Upd
set(value): Unit = set(4, value) set(value): Unit = set(4, value)
get(): LocalDateTime? = get(4) as LocalDateTime? get(): LocalDateTime? = get(4) as LocalDateTime?
open var progressCurrent: Int?
set(value): Unit = set(5, value)
get(): Int? = get(5) as Int?
open var progressTotal: Int?
set(value): Unit = set(6, value)
get(): Int? = get(6) as Int?
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Primary key information // Primary key information
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@ -48,26 +56,32 @@ open class UpdateUserQueueRecord private constructor() : UpdatableRecordImpl<Upd
override fun key(): Record1<Int?> = super.key() as Record1<Int?> override fun key(): Record1<Int?> = super.key() as Record1<Int?>
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Record5 type implementation // Record7 type implementation
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
override fun fieldsRow(): Row5<Int?, Long?, Boolean?, LocalDateTime?, LocalDateTime?> = super.fieldsRow() as Row5<Int?, Long?, Boolean?, LocalDateTime?, LocalDateTime?> override fun fieldsRow(): Row7<Int?, Long?, Boolean?, LocalDateTime?, LocalDateTime?, Int?, Int?> = super.fieldsRow() as Row7<Int?, Long?, Boolean?, LocalDateTime?, LocalDateTime?, Int?, Int?>
override fun valuesRow(): Row5<Int?, Long?, Boolean?, LocalDateTime?, LocalDateTime?> = super.valuesRow() as Row5<Int?, Long?, Boolean?, LocalDateTime?, LocalDateTime?> override fun valuesRow(): Row7<Int?, Long?, Boolean?, LocalDateTime?, LocalDateTime?, Int?, Int?> = super.valuesRow() as Row7<Int?, Long?, Boolean?, LocalDateTime?, LocalDateTime?, Int?, Int?>
override fun field1(): Field<Int?> = UpdateUserQueue.UPDATE_USER_QUEUE.ID override fun field1(): Field<Int?> = UpdateUserQueue.UPDATE_USER_QUEUE.ID
override fun field2(): Field<Long?> = UpdateUserQueue.UPDATE_USER_QUEUE.USER_ID override fun field2(): Field<Long?> = UpdateUserQueue.UPDATE_USER_QUEUE.USER_ID
override fun field3(): Field<Boolean?> = UpdateUserQueue.UPDATE_USER_QUEUE.PROCESSED override fun field3(): Field<Boolean?> = UpdateUserQueue.UPDATE_USER_QUEUE.PROCESSED
override fun field4(): Field<LocalDateTime?> = UpdateUserQueue.UPDATE_USER_QUEUE.CREATED_AT override fun field4(): Field<LocalDateTime?> = UpdateUserQueue.UPDATE_USER_QUEUE.CREATED_AT
override fun field5(): Field<LocalDateTime?> = UpdateUserQueue.UPDATE_USER_QUEUE.PROCESSED_AT override fun field5(): Field<LocalDateTime?> = UpdateUserQueue.UPDATE_USER_QUEUE.PROCESSED_AT
override fun field6(): Field<Int?> = UpdateUserQueue.UPDATE_USER_QUEUE.PROGRESS_CURRENT
override fun field7(): Field<Int?> = UpdateUserQueue.UPDATE_USER_QUEUE.PROGRESS_TOTAL
override fun component1(): Int? = id override fun component1(): Int? = id
override fun component2(): Long = userId override fun component2(): Long = userId
override fun component3(): Boolean? = processed override fun component3(): Boolean? = processed
override fun component4(): LocalDateTime? = createdAt override fun component4(): LocalDateTime? = createdAt
override fun component5(): LocalDateTime? = processedAt override fun component5(): LocalDateTime? = processedAt
override fun component6(): Int? = progressCurrent
override fun component7(): Int? = progressTotal
override fun value1(): Int? = id override fun value1(): Int? = id
override fun value2(): Long = userId override fun value2(): Long = userId
override fun value3(): Boolean? = processed override fun value3(): Boolean? = processed
override fun value4(): LocalDateTime? = createdAt override fun value4(): LocalDateTime? = createdAt
override fun value5(): LocalDateTime? = processedAt override fun value5(): LocalDateTime? = processedAt
override fun value6(): Int? = progressCurrent
override fun value7(): Int? = progressTotal
override fun value1(value: Int?): UpdateUserQueueRecord { override fun value1(value: Int?): UpdateUserQueueRecord {
set(0, value) set(0, value)
@ -94,24 +108,38 @@ open class UpdateUserQueueRecord private constructor() : UpdatableRecordImpl<Upd
return this return this
} }
override fun values(value1: Int?, value2: Long?, value3: Boolean?, value4: LocalDateTime?, value5: LocalDateTime?): UpdateUserQueueRecord { override fun value6(value: Int?): UpdateUserQueueRecord {
set(5, value)
return this
}
override fun value7(value: Int?): UpdateUserQueueRecord {
set(6, value)
return this
}
override fun values(value1: Int?, value2: Long?, value3: Boolean?, value4: LocalDateTime?, value5: LocalDateTime?, value6: Int?, value7: Int?): UpdateUserQueueRecord {
this.value1(value1) this.value1(value1)
this.value2(value2) this.value2(value2)
this.value3(value3) this.value3(value3)
this.value4(value4) this.value4(value4)
this.value5(value5) this.value5(value5)
this.value6(value6)
this.value7(value7)
return this return this
} }
/** /**
* Create a detached, initialised UpdateUserQueueRecord * Create a detached, initialised UpdateUserQueueRecord
*/ */
constructor(id: Int? = null, userId: Long, processed: Boolean? = null, createdAt: LocalDateTime? = null, processedAt: LocalDateTime? = null): this() { constructor(id: Int? = null, userId: Long, processed: Boolean? = null, createdAt: LocalDateTime? = null, processedAt: LocalDateTime? = null, progressCurrent: Int? = null, progressTotal: Int? = null): this() {
this.id = id this.id = id
this.userId = userId this.userId = userId
this.processed = processed this.processed = processed
this.createdAt = createdAt this.createdAt = createdAt
this.processedAt = processedAt this.processedAt = processedAt
this.progressCurrent = progressCurrent
this.progressTotal = progressTotal
resetChangedOnNotNull() resetChangedOnNotNull()
} }
} }

View File

@ -1,6 +1,15 @@
package com.nisemoe.nise package com.nisemoe.nise
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import java.time.LocalDateTime
data class UserQueueDetails(
val isProcessing: Boolean,
val lastCompletedUpdate: LocalDateTime?,
val progressCurrent: Int?,
val progressTotal: Int?,
)
data class UserDetails( data class UserDetails(
val user_id: Long, val user_id: Long,

View File

@ -0,0 +1,31 @@
package com.nisemoe.nise.config
import jakarta.servlet.*
import jakarta.servlet.http.HttpServletResponse
import org.springframework.beans.factory.annotation.Value
class CustomCorsFilter : Filter {
@Value("\${ORIGIN:http://localhost:4200}")
private lateinit var origin: String
override fun init(filterConfig: FilterConfig) {
// We don't really need to do anything special here.
}
override fun doFilter(servletRequest: ServletRequest, servletResponse: ServletResponse, filterChain: FilterChain) {
val response = servletResponse as HttpServletResponse
response.setHeader("Access-Control-Allow-Origin", origin)
response.setHeader("Access-Control-Allow-Methods", "GET,POST,DELETE,PUT,OPTIONS,PATCH")
response.setHeader(
"Access-Control-Allow-Headers",
"Access-Control-Allow-Headers, Origin,Accept, X-NISE-API, X-Requested-With, Content-Type, Access-Control-Request-Method, Access-Control-Request-Headers"
)
response.setHeader("Access-Control-Allow-Credentials", "true")
filterChain.doFilter(servletRequest, servletResponse)
}
override fun destroy() {
// We don't really need to do anything special here.
}
}

View File

@ -14,6 +14,7 @@ import org.springframework.security.web.authentication.logout.LogoutSuccessHandl
import org.springframework.session.web.http.CookieSerializer import org.springframework.session.web.http.CookieSerializer
import org.springframework.session.web.http.DefaultCookieSerializer import org.springframework.session.web.http.DefaultCookieSerializer
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import org.springframework.web.filter.CorsFilter
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@ -58,10 +59,16 @@ class SecurityConfig {
} }
@Bean
fun customCorsFilter(): CustomCorsFilter {
return CustomCorsFilter()
}
@Bean @Bean
fun filterChain(http: HttpSecurity): SecurityFilterChain? { fun filterChain(http: HttpSecurity): SecurityFilterChain? {
http http
.csrf { csrf -> csrf.disable() } .csrf { csrf -> csrf.disable() }
.addFilterBefore(customCorsFilter(), CorsFilter::class.java)
.oauth2Login { oauthLogin -> .oauth2Login { oauthLogin ->
oauthLogin.successHandler(CustomAuthenticationSuccessHandler()) oauthLogin.successHandler(CustomAuthenticationSuccessHandler())
} }

View File

@ -1,23 +0,0 @@
package com.nisemoe.nise.config
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Configuration
import org.springframework.web.servlet.config.annotation.CorsRegistry
import org.springframework.web.servlet.config.annotation.EnableWebMvc
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
@Configuration
@EnableWebMvc
class WebConfig: WebMvcConfigurer {
@Value("\${ORIGIN:http://localhost:4200}")
private lateinit var origin: String
override fun addCorsMappings(registry: CorsRegistry) {
registry.addMapping("/**")
.allowedOrigins(origin)
.allowCredentials(true)
}
}

View File

@ -5,24 +5,45 @@ import com.nisemoe.nise.SimilarReplayEntry
import com.nisemoe.nise.database.ScoreService import com.nisemoe.nise.database.ScoreService
import com.nisemoe.nise.SuspiciousScoreEntry import com.nisemoe.nise.SuspiciousScoreEntry
import com.nisemoe.nise.UserDetails import com.nisemoe.nise.UserDetails
import com.nisemoe.nise.UserQueueDetails
import com.nisemoe.nise.database.UserService import com.nisemoe.nise.database.UserService
import com.nisemoe.nise.service.UpdateUserQueueService
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import java.time.LocalDateTime
@RestController @RestController
class UserDetailsController( class UserDetailsController(
private val scoreService: ScoreService, private val scoreService: ScoreService,
private val userService: UserService private val userService: UserService,
private val userQueueService: UpdateUserQueueService
) { ) {
data class UserDetailsResponse( data class UserDetailsResponse(
val user_details: UserDetails, val user_details: UserDetails,
val queue_details: UserQueueDetails,
val suspicious_scores: List<SuspiciousScoreEntry>, val suspicious_scores: List<SuspiciousScoreEntry>,
val similar_replays: List<SimilarReplayEntry> val similar_replays: List<SimilarReplayEntry>,
val total_scores: Int = 0,
) )
data class UserQueueRequest(
val userId: Long
)
@PostMapping("user-queue")
fun addUserToQueue(@RequestBody request: UserQueueRequest): ResponseEntity<Unit> {
val inserted = this.userQueueService.insertUser(request.userId)
return if(inserted)
ResponseEntity.ok().build()
else
ResponseEntity.badRequest().build()
}
@GetMapping("user-details/{userId}") @GetMapping("user-details/{userId}")
fun getUserDetails(@PathVariable userId: String): ResponseEntity<UserDetailsResponse> { fun getUserDetails(@PathVariable userId: String): ResponseEntity<UserDetailsResponse> {
val userDetails = this.userService.getUserDetails(username = userId) val userDetails = this.userService.getUserDetails(username = userId)
@ -33,8 +54,10 @@ class UserDetailsController(
val response = UserDetailsResponse( val response = UserDetailsResponse(
user_details = userDetails, user_details = userDetails,
queue_details = this.userQueueService.getUserQueueDetails(userDetails.user_id),
suspicious_scores = this.scoreService.getSuspiciousScores(suspiciousScoresCondition), suspicious_scores = this.scoreService.getSuspiciousScores(suspiciousScoresCondition),
similar_replays = this.scoreService.getSimilarReplaysForUserId(userDetails.user_id) similar_replays = this.scoreService.getSimilarReplaysForUserId(userDetails.user_id),
total_scores = this.userService.getTotalUserScores(userDetails.user_id)
) )
return ResponseEntity.ok(response) return ResponseEntity.ok(response)
} }

View File

@ -1,11 +1,15 @@
package com.nisemoe.nise.database package com.nisemoe.nise.database
import com.nisemoe.generated.tables.records.UpdateUserQueueRecord
import com.nisemoe.generated.tables.records.UsersRecord import com.nisemoe.generated.tables.records.UsersRecord
import com.nisemoe.generated.tables.references.SCORES
import com.nisemoe.generated.tables.references.UPDATE_USER_QUEUE
import com.nisemoe.generated.tables.references.USERS import com.nisemoe.generated.tables.references.USERS
import com.nisemoe.nise.osu.OsuApi
import com.nisemoe.nise.osu.OsuApiModels
import com.nisemoe.nise.Format import com.nisemoe.nise.Format
import com.nisemoe.nise.UserDetails import com.nisemoe.nise.UserDetails
import com.nisemoe.nise.UserQueueDetails
import com.nisemoe.nise.osu.OsuApi
import com.nisemoe.nise.osu.OsuApiModels
import org.jooq.DSLContext import org.jooq.DSLContext
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
@ -36,6 +40,10 @@ class UserService(
} }
fun getTotalUserScores(userId: Long): Int {
return dslContext.fetchCount(SCORES, SCORES.USER_ID.eq(userId))
}
fun getUserDetails(username: String): UserDetails? { fun getUserDetails(username: String): UserDetails? {
val user = dslContext.selectFrom(USERS) val user = dslContext.selectFrom(USERS)
.where(USERS.USERNAME.equalIgnoreCase(username.lowercase())) .where(USERS.USERNAME.equalIgnoreCase(username.lowercase()))

View File

@ -6,6 +6,7 @@ import com.nisemoe.konata.Replay
import com.nisemoe.konata.ReplaySetComparison import com.nisemoe.konata.ReplaySetComparison
import com.nisemoe.konata.compareReplaySet import com.nisemoe.konata.compareReplaySet
import com.nisemoe.nise.Format.Companion.fromJudgementType import com.nisemoe.nise.Format.Companion.fromJudgementType
import com.nisemoe.nise.UserQueueDetails
import com.nisemoe.nise.database.ScoreService import com.nisemoe.nise.database.ScoreService
import com.nisemoe.nise.database.UserService import com.nisemoe.nise.database.UserService
import com.nisemoe.nise.integrations.CircleguardService import com.nisemoe.nise.integrations.CircleguardService
@ -169,6 +170,16 @@ class ImportScores(
} }
} }
var current = 0
val lastCompletedUpdate = dslContext.select(UPDATE_USER_QUEUE.PROCESSED_AT)
.from(UPDATE_USER_QUEUE)
.where(UPDATE_USER_QUEUE.USER_ID.eq(userId))
.and(UPDATE_USER_QUEUE.PROCESSED.isTrue)
.orderBy(UPDATE_USER_QUEUE.PROCESSED_AT.desc())
.limit(1)
.fetchOneInto(LocalDateTime::class.java)
for(topScore in topUserScores) { for(topScore in topUserScores) {
if(topScore.beatmap != null && topScore.beatmapset != null) { if(topScore.beatmap != null && topScore.beatmapset != null) {
val beatmapExists = dslContext.fetchExists(BEATMAPS, BEATMAPS.BEATMAP_ID.eq(topScore.beatmap.id)) val beatmapExists = dslContext.fetchExists(BEATMAPS, BEATMAPS.BEATMAP_ID.eq(topScore.beatmap.id))
@ -187,10 +198,34 @@ class ImportScores(
this.statistics.beatmapsAddedToDatabase++ this.statistics.beatmapsAddedToDatabase++
} }
// Update the database
dslContext.update(UPDATE_USER_QUEUE)
.set(UPDATE_USER_QUEUE.PROGRESS_CURRENT, current)
.set(UPDATE_USER_QUEUE.PROGRESS_TOTAL, topUserScores.size)
.where(UPDATE_USER_QUEUE.USER_ID.eq(userId))
.and(UPDATE_USER_QUEUE.PROCESSED.isFalse)
.execute()
val currentQueueDetails = UserQueueDetails(
isProcessing = true,
lastCompletedUpdate = lastCompletedUpdate,
progressCurrent = current,
progressTotal = topUserScores.size
)
// Update the frontend
messagingTemplate.convertAndSend(
"/topic/live-user/${userId}",
currentQueueDetails
)
this.insertAndProcessNewScore(topScore.beatmap.id, topScore) this.insertAndProcessNewScore(topScore.beatmap.id, topScore)
} }
current += 1
} }
} }
// Update the backend
this.updateUserQueueService.setUserAsProcessed(userId) this.updateUserQueueService.setUserAsProcessed(userId)
} }

View File

@ -1,15 +1,53 @@
package com.nisemoe.nise.service package com.nisemoe.nise.service
import com.nisemoe.generated.tables.records.UpdateUserQueueRecord
import com.nisemoe.generated.tables.references.UPDATE_USER_QUEUE import com.nisemoe.generated.tables.references.UPDATE_USER_QUEUE
import com.nisemoe.nise.UserQueueDetails
import org.jooq.DSLContext import org.jooq.DSLContext
import org.springframework.messaging.simp.SimpMessagingTemplate
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import java.time.LocalDateTime import java.time.LocalDateTime
@Service @Service
class UpdateUserQueueService( class UpdateUserQueueService(
private val dslContext: DSLContext private val dslContext: DSLContext,
private val messagingTemplate: SimpMessagingTemplate
) { ) {
fun getUserQueueDetails(userId: Long): UserQueueDetails {
val isProcessing = dslContext.fetchExists(
UPDATE_USER_QUEUE,
UPDATE_USER_QUEUE.USER_ID.eq(userId).and(UPDATE_USER_QUEUE.PROCESSED.isFalse)
)
val lastCompletedUpdate = dslContext.select(UPDATE_USER_QUEUE.PROCESSED_AT)
.from(UPDATE_USER_QUEUE)
.where(UPDATE_USER_QUEUE.USER_ID.eq(userId))
.and(UPDATE_USER_QUEUE.PROCESSED.isTrue)
.orderBy(UPDATE_USER_QUEUE.PROCESSED_AT.desc())
.limit(1)
.fetchOneInto(LocalDateTime::class.java)
val currentProgress = dslContext.select(
UPDATE_USER_QUEUE.PROGRESS_CURRENT,
UPDATE_USER_QUEUE.PROGRESS_TOTAL
)
.from(UPDATE_USER_QUEUE)
.where(UPDATE_USER_QUEUE.USER_ID.eq(userId))
.and(UPDATE_USER_QUEUE.PROCESSED.isFalse)
.orderBy(UPDATE_USER_QUEUE.PROCESSED_AT.desc())
.limit(1)
.fetchOneInto(UpdateUserQueueRecord::class.java)
return UserQueueDetails(
isProcessing,
lastCompletedUpdate,
currentProgress?.progressCurrent,
currentProgress?.progressTotal
)
}
fun getQueue(): List<Long> { fun getQueue(): List<Long> {
return dslContext.select(UPDATE_USER_QUEUE.USER_ID) return dslContext.select(UPDATE_USER_QUEUE.USER_ID)
.from(UPDATE_USER_QUEUE) .from(UPDATE_USER_QUEUE)
@ -18,17 +56,23 @@ class UpdateUserQueueService(
.fetchInto(Long::class.java) .fetchInto(Long::class.java)
} }
fun insertUser(userId: Long) { /**
* Inserts a user into the update queue if they are not already in the queue.
* @return true if the user was inserted, false if the user was already in the queue
*/
fun insertUser(userId: Long): Boolean {
val exists = dslContext.fetchExists(UPDATE_USER_QUEUE, val exists = dslContext.fetchExists(UPDATE_USER_QUEUE,
UPDATE_USER_QUEUE.USER_ID.eq(userId), UPDATE_USER_QUEUE.USER_ID.eq(userId),
UPDATE_USER_QUEUE.PROCESSED.isFalse UPDATE_USER_QUEUE.PROCESSED.isFalse
) )
if (exists) if (exists)
return return false
dslContext.insertInto(UPDATE_USER_QUEUE) val insertedRows = dslContext.insertInto(UPDATE_USER_QUEUE)
.set(UPDATE_USER_QUEUE.USER_ID, userId) .set(UPDATE_USER_QUEUE.USER_ID, userId)
.execute() .execute()
return insertedRows == 1
} }
fun setUserAsProcessed(userId: Long) { fun setUserAsProcessed(userId: Long) {
@ -38,7 +82,12 @@ class UpdateUserQueueService(
.where(UPDATE_USER_QUEUE.USER_ID.eq(userId)) .where(UPDATE_USER_QUEUE.USER_ID.eq(userId))
.and(UPDATE_USER_QUEUE.PROCESSED.isFalse) .and(UPDATE_USER_QUEUE.PROCESSED.isFalse)
.execute() .execute()
// Notify the user that their queue has been processed with fresh info
messagingTemplate.convertAndSend(
"/topic/live-user/${userId}",
this.getUserQueueDetails(userId)
)
} }
} }

View File

@ -0,0 +1,5 @@
ALTER TABLE public.update_user_queue
ADD COLUMN progress_current int;
ALTER TABLE public.update_user_queue
ADD COLUMN progress_total int;

View File

@ -11,3 +11,11 @@ export interface UserDetails {
suspicious_scores?: number; suspicious_scores?: number;
} }
export interface UserQueueDetails {
isProcessing: boolean;
lastCompletedUpdate: string | null;
progressCurrent: number | null;
progressTotal: number | null;
}

View File

@ -40,6 +40,24 @@
<li *ngIf="this.userInfo.user_details.playcount">Playcount: {{ this.userInfo.user_details.playcount | number: '1.0-1' }}</li> <li *ngIf="this.userInfo.user_details.playcount">Playcount: {{ this.userInfo.user_details.playcount | number: '1.0-1' }}</li>
</ul> </ul>
<ng-container *ngIf="!this.userInfo.queue_details.isProcessing; else updateProgress">
<a (click)="this.addUserToQueue()" class="btn btn-success pointer" target="_blank">
update user scores now!
</a> <span style="margin-left: 4px">|</span>
last update: {{ this.userInfo.queue_details.lastCompletedUpdate ? this.userInfo.queue_details.lastCompletedUpdate : 'never'}}
</ng-container>
<ng-template #updateProgress>
<div class="progress">
<span class="btn-warning">updating now!</span> <span style="margin-left: 4px">|</span> progress: {{ this.userInfo.queue_details.progressCurrent ? this.userInfo.queue_details.progressCurrent : "?" }}/{{ this.userInfo.queue_details.progressTotal ? this.userInfo.queue_details.progressTotal : "?" }}
<ng-container *ngIf="!this.userInfo.queue_details.progressTotal && !this.userInfo.queue_details.progressCurrent; else loading">
<span style="font-weight: bold">(in queue)</span>
</ng-container>
<ng-template #loading>
<app-cute-loading></app-cute-loading>
</ng-template>
</div>
</ng-template>
<h4>Suspicious Scores ({{ this.userInfo.suspicious_scores.length }})</h4> <h4>Suspicious Scores ({{ this.userInfo.suspicious_scores.length }})</h4>
<div class="table"> <div class="table">
<table class="table"> <table class="table">

View File

@ -1,18 +1,23 @@
import {Component, OnChanges, OnInit} from '@angular/core'; import {Component, OnChanges, OnDestroy, OnInit} from '@angular/core';
import {SimilarReplay, SuspiciousScore} from "../replays"; import {SimilarReplay, SuspiciousScore} from "../replays";
import {HttpClient} from "@angular/common/http"; import {HttpClient} from "@angular/common/http";
import {catchError, EMPTY, finalize, Observable} from "rxjs"; import {catchError, EMPTY, finalize, Observable, Subscription} from "rxjs";
import {environment} from "../../environments/environment"; import {environment} from "../../environments/environment";
import {DecimalPipe, JsonPipe, NgForOf, NgIf, NgOptimizedImage} from "@angular/common"; import {DecimalPipe, JsonPipe, NgForOf, NgIf, NgOptimizedImage} from "@angular/common";
import {ActivatedRoute, RouterLink} from "@angular/router"; import {ActivatedRoute, RouterLink} from "@angular/router";
import {UserDetails} from "../userDetails"; import {UserDetails, UserQueueDetails} from "../userDetails";
import {countryCodeToFlag, formatDuration} from "../format"; import {countryCodeToFlag, formatDuration} from "../format";
import {Title} from "@angular/platform-browser"; import {Title} from "@angular/platform-browser";
import {RxStompService} from "../../corelib/stomp/stomp.service";
import {Message} from "@stomp/stompjs/esm6";
import {CuteLoadingComponent} from "../../corelib/components/cute-loading/cute-loading.component";
interface UserInfo { interface UserInfo {
user_details: UserDetails; user_details: UserDetails;
queue_details: UserQueueDetails;
suspicious_scores: SuspiciousScore[]; suspicious_scores: SuspiciousScore[];
similar_replays: SimilarReplay[]; similar_replays: SimilarReplay[];
total_scores: number;
} }
@Component({ @Component({
@ -24,22 +29,26 @@ interface UserInfo {
NgForOf, NgForOf,
RouterLink, RouterLink,
NgIf, NgIf,
NgOptimizedImage NgOptimizedImage,
CuteLoadingComponent
], ],
templateUrl: './view-user.component.html', templateUrl: './view-user.component.html',
styleUrl: './view-user.component.css' styleUrl: './view-user.component.css'
}) })
export class ViewUserComponent implements OnInit, OnChanges { export class ViewUserComponent implements OnInit, OnChanges, OnDestroy {
isLoading = false; isLoading = false;
notFound = false; notFound = false;
userId: string | null = null; userId: string | null = null;
userInfo: UserInfo | null = null; userInfo: UserInfo | null = null;
liveUserSub: Subscription | undefined;
constructor( constructor(
private httpClient: HttpClient, private httpClient: HttpClient,
private activatedRoute: ActivatedRoute, private activatedRoute: ActivatedRoute,
private title: Title private title: Title,
private rxStompService: RxStompService,
) { } ) { }
getUserInfo(): Observable<UserInfo> { getUserInfo(): Observable<UserInfo> {
@ -74,14 +83,40 @@ export class ViewUserComponent implements OnInit, OnChanges {
this.notFound = false; this.notFound = false;
this.userInfo = response; this.userInfo = response;
this.title.setTitle(`${this.userInfo.user_details.username}`); this.title.setTitle(`${this.userInfo.user_details.username}`);
this.subscribeToUser();
} }
); );
} }
}); });
} }
protected readonly formatDuration = formatDuration; ngOnDestroy(): void {
this.liveUserSub?.unsubscribe();
}
addUserToQueue(): void {
const body = {
userId: this.userInfo?.user_details.user_id
}
this.httpClient.post(`${environment.apiUrl}/user-queue`, body).subscribe(
() => {
this.userInfo!.queue_details.isProcessing = true;
},
(error) => {
alert("Failed to add user to queue.");
}
)
}
subscribeToUser(): void {
this.liveUserSub = this.rxStompService
.watch(`/topic/live-user/${this.userInfo?.user_details.user_id}`)
.subscribe((message: Message) => {
this.userInfo!.queue_details = JSON.parse(message.body);
});
}
protected readonly formatDuration = formatDuration;
protected readonly countryCodeToFlag = countryCodeToFlag; protected readonly countryCodeToFlag = countryCodeToFlag;

View File

@ -141,6 +141,27 @@ a.btn {
border: 1px dotted #b3b8c3; border: 1px dotted #b3b8c3;
} }
a.btn-success {
color: #5eaf78;
border: 1px dotted #5eaf78;
}
a.btn-success:hover {
color: #2af171;
border: 1px dotted #2af171;
}
.btn-warning {
padding: 2px;
color: #f1c40f;
border: 1px dotted #f1c40f;
}
.btn-warning:hover {
color: #f1c40f;
border: 1px dotted #f1c40f;
}
.text-center { .text-center {
text-align: center; text-align: center;
} }

View File

@ -0,0 +1,20 @@
import { Component, OnInit } from '@angular/core';
@Component({
selector: 'app-cute-loading',
standalone: true,
templateUrl: './cute-loading.component.html'
})
export class CuteLoadingComponent implements OnInit {
dots = '';
ngOnInit() {
setInterval(() => {
this.dots += '.';
if (this.dots.length > 3) {
this.dots = '.';
}
}, 500);
}
}