Add admin dashboard filtering and sorting
CI / verify (push) Has been cancelled
CI / publish (push) Has been cancelled

Provide stable server-side list queries and focused chart controls so operators can inspect large datasets without misleading partial-page ordering.
This commit is contained in:
Rocky
2026-08-20 18:05:49 +08:00
parent 034a3e8745
commit 74c3fcd45f
39 changed files with 2990 additions and 431 deletions
@@ -20,11 +20,6 @@ data class AdminOperatorRecord(
val updatedAt: Instant,
)
data class AdminOperatorCursor(
val createdAt: Instant,
val id: UUID,
)
data class NewAdminOperator(
val id: UUID,
val normalizedUsername: String,
@@ -0,0 +1,48 @@
package com.osglab.account.features.admin.models
import java.time.Instant
import java.util.UUID
enum class AdminSortOrder {
ASC,
DESC,
}
data class AdminTimeFilter(
val from: Instant? = null,
val until: Instant? = null,
) {
init {
require(from == null || until == null || from < until) {
"Admin query time range must be non-empty"
}
}
}
data class AdminAuditQuery(
val time: AdminTimeFilter = AdminTimeFilter(),
val action: AdminAuditAction? = null,
val outcome: AdminAuditOutcome? = null,
val order: AdminSortOrder = AdminSortOrder.DESC,
)
enum class AdminOperatorSort {
CREATED_AT,
USERNAME,
LAST_LOGIN_AT,
}
data class AdminOperatorQuery(
val time: AdminTimeFilter = AdminTimeFilter(),
val role: AdminRole? = null,
val enabled: Boolean? = null,
val locked: Boolean? = null,
val sort: AdminOperatorSort = AdminOperatorSort.CREATED_AT,
val order: AdminSortOrder = AdminSortOrder.ASC,
val now: Instant,
)
data class AdminOperatorCursor(
val value: String?,
val id: UUID,
)
@@ -1,6 +1,7 @@
package com.osglab.account.features.admin.repositories
import com.osglab.account.config.DatabaseFactory
import com.osglab.account.features.admin.models.AdminAuditQuery
import com.osglab.account.features.admin.models.AdminAuditCursor
import com.osglab.account.features.admin.models.AdminLockState
import com.osglab.account.features.admin.models.AdminAuditAction
@@ -8,10 +9,13 @@ import com.osglab.account.features.admin.models.AdminAuditOutcome
import com.osglab.account.features.admin.models.AdminAuditRecord
import com.osglab.account.features.admin.models.AdminOperatorAuthRecord
import com.osglab.account.features.admin.models.AdminOperatorCursor
import com.osglab.account.features.admin.models.AdminOperatorQuery
import com.osglab.account.features.admin.models.AdminOperatorSort
import com.osglab.account.features.admin.models.AdminOperatorMutationResult
import com.osglab.account.features.admin.models.AdminOperatorRecord
import com.osglab.account.features.admin.models.AdminRole
import com.osglab.account.features.admin.models.AdminSessionRecord
import com.osglab.account.features.admin.models.AdminSortOrder
import com.osglab.account.features.admin.models.NewAdminAuditEvent
import com.osglab.account.features.admin.models.NewAdminOperator
import com.osglab.account.features.admin.models.NewAdminSession
@@ -21,6 +25,7 @@ import org.jetbrains.exposed.v1.core.Table
import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.core.greater
import org.jetbrains.exposed.v1.core.greaterEq
import org.jetbrains.exposed.v1.core.inList
import org.jetbrains.exposed.v1.core.isNotNull
import org.jetbrains.exposed.v1.core.isNull
@@ -86,6 +91,7 @@ interface AdminRepository {
suspend fun listOperatorsPage(
limit: Int,
before: AdminOperatorCursor? = null,
query: AdminOperatorQuery,
): List<AdminOperatorRecord>
suspend fun countActiveSessions(now: Instant): Long
suspend fun findOperator(operatorId: UUID): AdminOperatorRecord?
@@ -148,6 +154,7 @@ interface AdminRepository {
suspend fun listAudit(
limit: Int,
before: AdminAuditCursor? = null,
query: AdminAuditQuery = AdminAuditQuery(),
): List<AdminAuditRecord>
}
@@ -185,24 +192,119 @@ class ExposedAdminRepository(
override suspend fun listOperatorsPage(
limit: Int,
before: AdminOperatorCursor?,
query: AdminOperatorQuery,
): List<AdminOperatorRecord> =
databaseFactory.query {
require(limit in 1..101)
val query = AdminOperatorsTable.selectAll()
if (before != null) {
query.andWhere {
(AdminOperatorsTable.createdAt greater before.createdAt) or
(
(AdminOperatorsTable.createdAt eq before.createdAt) and
(AdminOperatorsTable.id greater before.id.toString())
)
val statement = AdminOperatorsTable.selectAll()
query.time.from?.let { from ->
statement.andWhere { AdminOperatorsTable.createdAt greaterEq from }
}
query.time.until?.let { until ->
statement.andWhere { AdminOperatorsTable.createdAt less until }
}
query.role?.let { role ->
statement.andWhere { AdminOperatorsTable.role eq role.name }
}
query.enabled?.let { enabled ->
statement.andWhere {
if (enabled) AdminOperatorsTable.disabledAt.isNull()
else AdminOperatorsTable.disabledAt.isNotNull()
}
}
query
.orderBy(
AdminOperatorsTable.createdAt to SortOrder.ASC,
AdminOperatorsTable.id to SortOrder.ASC,
query.locked?.let { locked ->
statement.andWhere {
if (locked) {
AdminOperatorsTable.lockedUntil.isNotNull() and
(AdminOperatorsTable.lockedUntil greater query.now)
} else {
AdminOperatorsTable.lockedUntil.isNull() or
(AdminOperatorsTable.lockedUntil lessEq query.now)
}
}
}
if (before != null) {
statement.andWhere {
val ascending = query.order == AdminSortOrder.ASC
when (query.sort) {
AdminOperatorSort.CREATED_AT -> {
val value = Instant.parse(requireNotNull(before.value))
if (ascending) {
(AdminOperatorsTable.createdAt greater value) or
(
(AdminOperatorsTable.createdAt eq value) and
(AdminOperatorsTable.id greater before.id.toString())
)
} else {
(AdminOperatorsTable.createdAt less value) or
(
(AdminOperatorsTable.createdAt eq value) and
(AdminOperatorsTable.id less before.id.toString())
)
}
}
AdminOperatorSort.USERNAME -> {
val value = requireNotNull(before.value)
if (ascending) {
(AdminOperatorsTable.username greater value) or
(
(AdminOperatorsTable.username eq value) and
(AdminOperatorsTable.id greater before.id.toString())
)
} else {
(AdminOperatorsTable.username less value) or
(
(AdminOperatorsTable.username eq value) and
(AdminOperatorsTable.id less before.id.toString())
)
}
}
AdminOperatorSort.LAST_LOGIN_AT -> {
val value = before.value?.let(Instant::parse)
if (value == null) {
AdminOperatorsTable.lastLoginAt.isNull() and
if (ascending) {
AdminOperatorsTable.id greater before.id.toString()
} else {
AdminOperatorsTable.id less before.id.toString()
}
} else {
val nonNullAfter = if (ascending) {
(AdminOperatorsTable.lastLoginAt greater value) or
(
(AdminOperatorsTable.lastLoginAt eq value) and
(AdminOperatorsTable.id greater before.id.toString())
)
} else {
(AdminOperatorsTable.lastLoginAt less value) or
(
(AdminOperatorsTable.lastLoginAt eq value) and
(AdminOperatorsTable.id less before.id.toString())
)
}
nonNullAfter or AdminOperatorsTable.lastLoginAt.isNull()
}
}
}
}
}
val sortOrder = query.order.toExposedSortOrder()
when (query.sort) {
AdminOperatorSort.CREATED_AT -> statement.orderBy(
AdminOperatorsTable.createdAt to sortOrder,
AdminOperatorsTable.id to sortOrder,
)
AdminOperatorSort.USERNAME -> statement.orderBy(
AdminOperatorsTable.username to sortOrder,
AdminOperatorsTable.id to sortOrder,
)
AdminOperatorSort.LAST_LOGIN_AT -> statement.orderBy(
AdminOperatorsTable.lastLoginAt.isNull() to SortOrder.ASC,
AdminOperatorsTable.lastLoginAt to sortOrder,
AdminOperatorsTable.id to sortOrder,
)
}
statement
.limit(limit)
.map(ResultRow::toOperatorRecord)
}
@@ -495,23 +597,45 @@ class ExposedAdminRepository(
override suspend fun listAudit(
limit: Int,
before: AdminAuditCursor?,
query: AdminAuditQuery,
): List<AdminAuditRecord> =
databaseFactory.query {
require(limit in 1..101)
val query = AdminAuditLogTable.selectAll()
val statement = AdminAuditLogTable.selectAll()
query.time.from?.let { from ->
statement.andWhere { AdminAuditLogTable.occurredAt greaterEq from }
}
query.time.until?.let { until ->
statement.andWhere { AdminAuditLogTable.occurredAt less until }
}
query.action?.let { action ->
statement.andWhere { AdminAuditLogTable.action eq action.name }
}
query.outcome?.let { outcome ->
statement.andWhere { AdminAuditLogTable.outcome eq outcome.name }
}
if (before != null) {
query.andWhere {
(AdminAuditLogTable.occurredAt less before.occurredAt) or
(
(AdminAuditLogTable.occurredAt eq before.occurredAt) and
(AdminAuditLogTable.id less before.id.toString())
)
statement.andWhere {
if (query.order == AdminSortOrder.ASC) {
(AdminAuditLogTable.occurredAt greater before.occurredAt) or
(
(AdminAuditLogTable.occurredAt eq before.occurredAt) and
(AdminAuditLogTable.id greater before.id.toString())
)
} else {
(AdminAuditLogTable.occurredAt less before.occurredAt) or
(
(AdminAuditLogTable.occurredAt eq before.occurredAt) and
(AdminAuditLogTable.id less before.id.toString())
)
}
}
}
query
val sortOrder = query.order.toExposedSortOrder()
statement
.orderBy(
AdminAuditLogTable.occurredAt to SortOrder.DESC,
AdminAuditLogTable.id to SortOrder.DESC,
AdminAuditLogTable.occurredAt to sortOrder,
AdminAuditLogTable.id to sortOrder,
)
.limit(limit)
.map {
@@ -528,6 +652,9 @@ class ExposedAdminRepository(
}
}
private fun AdminSortOrder.toExposedSortOrder(): SortOrder =
if (this == AdminSortOrder.ASC) SortOrder.ASC else SortOrder.DESC
private fun insertAudit(event: NewAdminAuditEvent) {
AdminAuditLogTable.insert {
it[id] = event.id.toString()
@@ -3,10 +3,17 @@ package com.osglab.account.features.admin.routes
import com.osglab.account.config.AppConfig
import com.osglab.account.features.admin.grants.models.ManualGrantCommand
import com.osglab.account.features.admin.grants.services.AdminGrantService
import com.osglab.account.features.admin.models.AdminAuditAction
import com.osglab.account.features.admin.models.AdminAuditOutcome
import com.osglab.account.features.admin.models.AdminAuditQuery
import com.osglab.account.features.admin.models.AdminLoginResult
import com.osglab.account.features.admin.models.AdminOperatorQuery
import com.osglab.account.features.admin.models.AdminOperatorRecord
import com.osglab.account.features.admin.models.AdminOperatorSort
import com.osglab.account.features.admin.models.AdminPrincipal
import com.osglab.account.features.admin.models.AdminRole
import com.osglab.account.features.admin.models.AdminOperatorRecord
import com.osglab.account.features.admin.models.AdminSortOrder
import com.osglab.account.features.admin.models.AdminTimeFilter
import com.osglab.account.features.admin.services.AdminAuditCursorException
import com.osglab.account.features.admin.services.AdminAuditService
import com.osglab.account.features.admin.services.AdminAuthService
@@ -19,11 +26,16 @@ import com.osglab.account.features.admin.services.AdminSessionService
import com.osglab.account.features.admin.stats.models.AdminStatsDto
import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto
import com.osglab.account.features.admin.stats.services.AdminProductAnalyticsService
import com.osglab.account.features.admin.stats.services.AdminReferralSort
import com.osglab.account.features.admin.stats.services.AdminStatsService
import com.osglab.account.features.admin.users.models.AdminUserDetailDto
import com.osglab.account.features.admin.users.models.AdminUserLedgerEntryDto
import com.osglab.account.features.admin.users.models.AdminUserReferralDto
import com.osglab.account.features.admin.users.models.AdminUserSummaryDto
import com.osglab.account.features.admin.users.repositories.AdminLedgerQuery
import com.osglab.account.features.admin.users.repositories.AdminLedgerType
import com.osglab.account.features.admin.users.repositories.AdminUserListQuery
import com.osglab.account.features.admin.users.repositories.AdminUserStatus
import com.osglab.account.features.admin.users.services.AdminUserNotFoundException
import com.osglab.account.features.admin.users.services.AdminUsersService
import com.osglab.account.features.credits.domain.CreditConflict
@@ -51,6 +63,9 @@ import io.ktor.server.routing.route
import kotlinx.serialization.Serializable
import java.time.Clock
import java.time.Duration
import java.time.Instant
import java.time.OffsetDateTime
import java.time.ZoneOffset
import java.util.UUID
fun Route.adminWebRoutes(config: AppConfig) {
@@ -159,7 +174,18 @@ fun Route.adminApiRoutes(
get("/referrals") {
if (call.requirePrincipal(config, sessionService) == null) return@get
val stats = statsService.getRange(call.request.queryParameters["range"], clock)
val options = runCatching { call.adminReferralQuery() }.getOrNull()
?: run {
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
return@get
}
val stats = statsService.getRange(
range = call.request.queryParameters["range"],
clock = clock,
referralRankLimit = options.limit,
referralSort = options.sort,
referralOrder = options.order,
)
?: run {
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
return@get
@@ -185,6 +211,11 @@ fun Route.adminApiRoutes(
setOf(AdminRole.SUPER_ADMIN, AdminRole.SUPPORT),
) == null
) return@get
val listQuery = runCatching { call.adminUserListQuery() }.getOrNull()
?: run {
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
return@get
}
val limit = call.pageLimit(maximum = 100) ?: run {
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
return@get
@@ -195,9 +226,10 @@ fun Route.adminApiRoutes(
usersService.list(
limit = limit,
cursor = call.request.queryParameters["cursor"],
query = listQuery,
)
} else {
usersService.searchByInternalId(query)
usersService.searchByInternalId(query, listQuery)
}
} catch (_: IllegalArgumentException) {
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
@@ -236,6 +268,11 @@ fun Route.adminApiRoutes(
) == null
) return@get
val userId = call.uuidPathParameter("userId") ?: return@get
val ledgerQuery = runCatching { call.adminLedgerQuery() }.getOrNull()
?: run {
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
return@get
}
val limit = call.pageLimit(maximum = 100, default = 100) ?: run {
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
return@get
@@ -245,6 +282,7 @@ fun Route.adminApiRoutes(
userId = userId,
limit = limit,
cursor = call.request.queryParameters["cursor"],
query = ledgerQuery,
)
call.respond(
PageResponse(
@@ -267,6 +305,11 @@ fun Route.adminApiRoutes(
setOf(AdminRole.SUPER_ADMIN, AdminRole.SUPPORT),
) == null
) return@get
val ledgerQuery = runCatching { call.adminLedgerQuery() }.getOrNull()
?: run {
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
return@get
}
val limit = call.pageLimit(maximum = 100, default = 100) ?: run {
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
return@get
@@ -275,6 +318,7 @@ fun Route.adminApiRoutes(
val page = usersService.latestLedger(
limit = limit,
cursor = call.request.queryParameters["cursor"],
query = ledgerQuery,
)
call.respond(
PageResponse(
@@ -353,6 +397,11 @@ fun Route.adminApiRoutes(
get("/operators") {
val principal = call.requirePrincipal(config, sessionService) ?: return@get
val operatorQuery = runCatching { call.adminOperatorQuery(clock.instant()) }.getOrNull()
?: run {
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
return@get
}
val limit = call.pageLimit(maximum = 100) ?: run {
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
return@get
@@ -362,6 +411,7 @@ fun Route.adminApiRoutes(
actor = principal,
cursor = call.request.queryParameters["cursor"],
limit = limit,
query = operatorQuery,
)
call.respond(
PageResponse(
@@ -480,6 +530,11 @@ fun Route.adminApiRoutes(
get("/audit") {
val principal = call.requirePrincipal(config, sessionService) ?: return@get
val auditQuery = runCatching { call.adminAuditQuery() }.getOrNull()
?: run {
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
return@get
}
val limit = call.pageLimit(maximum = 100) ?: run {
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
return@get
@@ -489,6 +544,7 @@ fun Route.adminApiRoutes(
actor = principal,
cursor = call.request.queryParameters["cursor"],
limit = limit,
query = auditQuery,
)
} catch (exception: AdminOperatorException) {
call.respondOperatorError(exception)
@@ -522,9 +578,18 @@ fun Route.adminApiRoutes(
private suspend fun AdminStatsService.getRange(
range: String?,
clock: Clock,
referralRankLimit: Int = 20,
referralSort: AdminReferralSort? = null,
referralOrder: AdminSortOrder = AdminSortOrder.DESC,
): AdminStatsDto? {
val window = parseAdminStatsRange(range, clock) ?: return null
return get(window.first, window.second)
return get(
from = window.first,
until = window.second,
referralRankLimit = referralRankLimit,
referralSort = referralSort,
referralOrder = referralOrder,
)
}
private fun parseAdminStatsRange(range: String?, clock: Clock): Pair<java.time.Instant, java.time.Instant>? {
@@ -538,6 +603,161 @@ private fun parseAdminStatsRange(range: String?, clock: Clock): Pair<java.time.I
return until.minus(Duration.ofDays(days)) to until
}
private data class AdminReferralQueryOptions(
val sort: AdminReferralSort?,
val order: AdminSortOrder,
val limit: Int,
)
private fun ApplicationCall.adminReferralQuery(): AdminReferralQueryOptions {
requireQueryParameters(setOf("range", "sort", "order", "limit"))
val explicitSort = request.queryParameters["sort"]?.let {
when (it) {
"invited" -> AdminReferralSort.INVITED
"qualified" -> AdminReferralSort.QUALIFIED
"creditsEarned" -> AdminReferralSort.CREDITS_EARNED
else -> throw IllegalArgumentException("Invalid referral sort")
}
}
val orderValue = request.queryParameters["order"]
val order = parseSortOrder(orderValue, AdminSortOrder.DESC)
val limit = request.queryParameters["limit"]?.toIntOrNull()?.takeIf { it in 1..100 } ?: run {
require(request.queryParameters["limit"] == null)
20
}
return AdminReferralQueryOptions(
sort = explicitSort ?: if (orderValue != null) AdminReferralSort.INVITED else null,
order = order,
limit = limit,
)
}
private fun ApplicationCall.adminUserListQuery(): AdminUserListQuery {
requireQueryParameters(setOf("q", "cursor", "limit", "from", "until", "status", "sort", "order"))
request.queryParameters["q"]?.let { require(it.trim().length <= 36) }
requireCreatedAtSort()
val status = request.queryParameters["status"]?.let {
when (it) {
"active" -> AdminUserStatus.ACTIVE
"suspended" -> AdminUserStatus.SUSPENDED
else -> throw IllegalArgumentException("Invalid user status")
}
}
return AdminUserListQuery(
time = adminTimeFilter(),
status = status,
order = parseSortOrder(request.queryParameters["order"], AdminSortOrder.DESC),
)
}
private fun ApplicationCall.adminLedgerQuery(): AdminLedgerQuery {
requireQueryParameters(setOf("cursor", "limit", "from", "until", "type", "sort", "order"))
requireCreatedAtSort()
val type = request.queryParameters["type"]?.let {
when (it) {
"reserve" -> AdminLedgerType.RESERVE
"settle" -> AdminLedgerType.SETTLE
"refund" -> AdminLedgerType.REFUND
"grant" -> AdminLedgerType.GRANT
"adjustment" -> AdminLedgerType.ADJUSTMENT
else -> throw IllegalArgumentException("Invalid ledger type")
}
}
return AdminLedgerQuery(
time = adminTimeFilter(),
type = type,
order = parseSortOrder(request.queryParameters["order"], AdminSortOrder.DESC),
)
}
private fun ApplicationCall.adminOperatorQuery(now: Instant): AdminOperatorQuery {
requireQueryParameters(
setOf("cursor", "limit", "from", "until", "role", "enabled", "locked", "sort", "order"),
)
val role = request.queryParameters["role"]?.let {
runCatching { AdminRole.valueOf(it) }.getOrNull()
?: throw IllegalArgumentException("Invalid operator role")
}
val sort = request.queryParameters["sort"]?.let {
when (it) {
"createdAt" -> AdminOperatorSort.CREATED_AT
"username" -> AdminOperatorSort.USERNAME
"lastLoginAt" -> AdminOperatorSort.LAST_LOGIN_AT
else -> throw IllegalArgumentException("Invalid operator sort")
}
} ?: AdminOperatorSort.CREATED_AT
return AdminOperatorQuery(
time = adminTimeFilter(),
role = role,
enabled = request.queryParameters["enabled"]?.let(::parseStrictBoolean),
locked = request.queryParameters["locked"]?.let(::parseStrictBoolean),
sort = sort,
order = parseSortOrder(request.queryParameters["order"], AdminSortOrder.ASC),
now = now,
)
}
private fun ApplicationCall.adminAuditQuery(): AdminAuditQuery {
requireQueryParameters(
setOf("cursor", "limit", "from", "until", "action", "result", "sort", "order"),
)
requireCreatedAtSort()
val action = request.queryParameters["action"]?.let {
runCatching { AdminAuditAction.valueOf(it) }.getOrNull()
?: throw IllegalArgumentException("Invalid audit action")
}
val outcome = request.queryParameters["result"]?.let {
when (it) {
"success" -> AdminAuditOutcome.SUCCESS
"rejected" -> AdminAuditOutcome.DENIED
else -> throw IllegalArgumentException("Invalid audit result")
}
}
return AdminAuditQuery(
time = adminTimeFilter(),
action = action,
outcome = outcome,
order = parseSortOrder(request.queryParameters["order"], AdminSortOrder.DESC),
)
}
private fun ApplicationCall.adminTimeFilter(): AdminTimeFilter =
AdminTimeFilter(
from = request.queryParameters["from"]?.let(::parseUtcInstant),
until = request.queryParameters["until"]?.let(::parseUtcInstant),
)
private fun parseUtcInstant(value: String): Instant {
val parsed = OffsetDateTime.parse(value)
require(parsed.offset == ZoneOffset.UTC)
return parsed.toInstant()
}
private fun ApplicationCall.requireCreatedAtSort() {
request.queryParameters["sort"]?.let { require(it == "createdAt") }
}
private fun ApplicationCall.requireQueryParameters(allowed: Set<String>) {
val parameters = request.queryParameters
require(parameters.names().all { it in allowed })
require(parameters.names().all { parameters.getAll(it).orEmpty().size == 1 })
}
private fun parseSortOrder(value: String?, default: AdminSortOrder): AdminSortOrder =
when (value) {
null -> default
"asc" -> AdminSortOrder.ASC
"desc" -> AdminSortOrder.DESC
else -> throw IllegalArgumentException("Invalid sort order")
}
private fun parseStrictBoolean(value: String): Boolean =
when (value) {
"true" -> true
"false" -> false
else -> throw IllegalArgumentException("Invalid boolean filter")
}
private suspend fun ApplicationCall.requirePrincipal(
config: AppConfig,
sessions: AdminSessionService,
@@ -1,9 +1,11 @@
package com.osglab.account.features.admin.services
import com.osglab.account.features.admin.models.AdminAuditCursor
import com.osglab.account.features.admin.models.AdminAuditQuery
import com.osglab.account.features.admin.models.AdminAuditRecord
import com.osglab.account.features.admin.models.AdminPrincipal
import com.osglab.account.features.admin.models.AdminRole
import com.osglab.account.features.admin.models.AdminSortOrder
import com.osglab.account.features.admin.repositories.AdminRepository
import java.nio.charset.StandardCharsets
import java.time.Instant
@@ -29,13 +31,14 @@ class AdminAuditService(
actor: AdminPrincipal,
cursor: String?,
limit: Int = DEFAULT_PAGE_SIZE,
query: AdminAuditQuery = AdminAuditQuery(),
): AdminAuditPage {
if (actor.role != AdminRole.SUPER_ADMIN) {
throw AdminOperatorException(AdminOperatorErrorCode.INSUFFICIENT_PERMISSION)
}
require(limit in 1..MAX_PAGE_SIZE)
val decodedCursor = cursor?.let(::decodeCursor)
val records = repository.listAudit(limit + 1, decodedCursor)
val decodedCursor = cursor?.let { decodeCursor(it, query.order) }
val records = repository.listAudit(limit + 1, decodedCursor, query)
val pageRecords = records.take(limit)
val operatorNames = repository.listOperators().associate {
it.id to it.normalizedUsername
@@ -50,36 +53,41 @@ class AdminAuditService(
)
},
nextCursor = if (records.size > limit) {
pageRecords.lastOrNull()?.let(::encodeCursor)
pageRecords.lastOrNull()?.let { encodeCursor(it, query.order) }
} else {
null
},
)
}
private fun decodeCursor(value: String): AdminAuditCursor {
private fun decodeCursor(value: String, expectedOrder: AdminSortOrder): AdminAuditCursor {
if (value.length !in 1..MAX_CURSOR_LENGTH) throw AdminAuditCursorException()
return runCatching {
val decoded = String(
Base64.getUrlDecoder().decode(value),
StandardCharsets.UTF_8,
)
val parts = decoded.split(':', limit = 3)
require(parts.size == 3)
val parts = decoded.split(':', limit = 5)
require(parts.size == 5)
require(parts[0] == "v1")
require(parts[1] == expectedOrder.name)
AdminAuditCursor(
occurredAt = Instant.ofEpochSecond(
parts[0].toLong(),
parts[1].toLong(),
parts[2].toLong(),
parts[3].toLong(),
),
id = UUID.fromString(parts[2]),
id = UUID.fromString(parts[4]),
)
}.getOrElse {
throw AdminAuditCursorException()
}
}
private fun encodeCursor(record: AdminAuditRecord): String {
private fun encodeCursor(record: AdminAuditRecord, order: AdminSortOrder): String {
val payload = buildString {
append("v1:")
append(order.name)
append(':')
append(record.occurredAt.epochSecond)
append(':')
append(record.occurredAt.nano)
@@ -5,9 +5,12 @@ import com.osglab.account.features.admin.models.AdminAuditAction
import com.osglab.account.features.admin.models.AdminAuditOutcome
import com.osglab.account.features.admin.models.AdminOperatorMutationResult
import com.osglab.account.features.admin.models.AdminOperatorCursor
import com.osglab.account.features.admin.models.AdminOperatorQuery
import com.osglab.account.features.admin.models.AdminOperatorRecord
import com.osglab.account.features.admin.models.AdminOperatorSort
import com.osglab.account.features.admin.models.AdminPrincipal
import com.osglab.account.features.admin.models.AdminRole
import com.osglab.account.features.admin.models.AdminSortOrder
import com.osglab.account.features.admin.models.NewAdminAuditEvent
import com.osglab.account.features.admin.models.NewAdminOperator
import com.osglab.account.features.admin.repositories.AdminRepository
@@ -15,9 +18,8 @@ import com.osglab.account.features.admin.security.AdminPasswordHasher
import com.osglab.account.features.admin.security.AdminTotpProvisioning
import com.osglab.account.features.admin.security.AdminTotpSecretGenerator
import com.osglab.account.features.admin.security.SecureAdminTotpSecretGenerator
import java.time.Clock
import java.time.Instant
import java.nio.charset.StandardCharsets
import java.time.Clock
import java.util.Base64
import java.util.Locale
import java.util.UUID
@@ -73,17 +75,33 @@ class AdminOperatorService(
actor: AdminPrincipal,
cursor: String?,
limit: Int = DEFAULT_PAGE_SIZE,
): AdminOperatorPage = listPage(
actor = actor,
cursor = cursor,
limit = limit,
query = AdminOperatorQuery(now = clock.instant()),
)
suspend fun listPage(
actor: AdminPrincipal,
cursor: String?,
limit: Int = DEFAULT_PAGE_SIZE,
query: AdminOperatorQuery,
): AdminOperatorPage {
requireSuperAdministrator(actor)
if (limit !in 1..MAX_PAGE_SIZE) {
throw AdminOperatorCursorException()
}
val decodedCursor = cursor?.let(::decodeCursor)
val records = repository.listOperatorsPage(limit + 1, decodedCursor)
val decodedCursor = cursor?.let { decodeCursor(it, query) }
val records = repository.listOperatorsPage(limit + 1, decodedCursor, query)
val items = records.take(limit)
return AdminOperatorPage(
items = items,
nextCursor = if (records.size > limit) items.lastOrNull()?.let(::encodeCursor) else null,
nextCursor = if (records.size > limit) {
items.lastOrNull()?.let { encodeCursor(it, query) }
} else {
null
},
)
}
@@ -370,28 +388,37 @@ class AdminOperatorService(
const val MAX_PASSWORD_CHARS = 1_024
const val MAX_AUDIT_TARGET_CHARS = 128
const val OPERATOR_TARGET = "ADMIN_OPERATOR"
const val NULL_CURSOR_VALUE = "~"
}
private fun decodeCursor(value: String): AdminOperatorCursor {
private fun decodeCursor(value: String, query: AdminOperatorQuery): AdminOperatorCursor {
if (value.length !in 1..MAX_CURSOR_LENGTH) throw AdminOperatorCursorException()
return runCatching {
val decoded = String(
Base64.getUrlDecoder().decode(value),
StandardCharsets.UTF_8,
)
val parts = decoded.split(':', limit = 3)
require(parts.size == 3)
val parts = decoded.split('|')
require(parts.size == 5)
require(parts[0] == "v1")
require(parts[1] == query.sort.name)
require(parts[2] == query.order.name)
AdminOperatorCursor(
createdAt = Instant.ofEpochSecond(parts[0].toLong(), parts[1].toLong()),
id = UUID.fromString(parts[2]),
value = parts[3].takeUnless { it == NULL_CURSOR_VALUE },
id = UUID.fromString(parts[4]),
)
}.getOrElse {
throw AdminOperatorCursorException()
}
}
private fun encodeCursor(record: AdminOperatorRecord): String {
val payload = "${record.createdAt.epochSecond}:${record.createdAt.nano}:${record.id}"
private fun encodeCursor(record: AdminOperatorRecord, query: AdminOperatorQuery): String {
val value = when (query.sort) {
AdminOperatorSort.CREATED_AT -> record.createdAt.toString()
AdminOperatorSort.USERNAME -> record.normalizedUsername
AdminOperatorSort.LAST_LOGIN_AT -> record.lastLoginAt?.toString() ?: NULL_CURSOR_VALUE
}
val payload = "v1|${query.sort.name}|${query.order.name}|$value|${record.id}"
return Base64.getUrlEncoder().withoutPadding().encodeToString(
payload.toByteArray(StandardCharsets.UTF_8),
)
@@ -1,6 +1,8 @@
package com.osglab.account.features.admin.stats.services
import com.osglab.account.features.admin.models.AdminSortOrder
import com.osglab.account.features.admin.stats.models.AdminCreditFlowPointDto
import com.osglab.account.features.admin.stats.models.AdminReferralRankDto
import com.osglab.account.features.admin.stats.models.AdminRegistrationPointDto
import com.osglab.account.features.admin.stats.models.AdminStatsDto
import com.osglab.account.features.admin.stats.models.AdminStatsPeriodDto
@@ -10,6 +12,12 @@ import java.time.Instant
import java.time.LocalDate
import java.time.ZoneOffset
enum class AdminReferralSort {
INVITED,
QUALIFIED,
CREDITS_EARNED,
}
class AdminStatsService(
private val repository: AdminStatsRepository,
) {
@@ -17,6 +25,8 @@ class AdminStatsService(
from: Instant,
until: Instant,
referralRankLimit: Int = 20,
referralSort: AdminReferralSort? = null,
referralOrder: AdminSortOrder = AdminSortOrder.DESC,
): AdminStatsDto {
require(from < until) { "Statistics range must be non-empty" }
require(referralRankLimit in 1..100) { "Referral rank limit must be between 1 and 100" }
@@ -40,12 +50,30 @@ class AdminStatsService(
)
},
referralFunnel = snapshot.referralFunnel,
referralRanking = snapshot.referralRanking.take(referralRankLimit),
referralRanking = snapshot.referralRanking
.sortedForReferralRanking(referralSort, referralOrder)
.take(referralRankLimit),
usage = snapshot.usage,
)
}
}
private fun List<AdminReferralRankDto>.sortedForReferralRanking(
sort: AdminReferralSort?,
order: AdminSortOrder,
): List<AdminReferralRankDto> {
if (sort == null) return this
val direction = if (order == AdminSortOrder.ASC) 1 else -1
return sortedWith { left, right ->
val primary = when (sort) {
AdminReferralSort.INVITED -> left.invitedUsers.compareTo(right.invitedUsers)
AdminReferralSort.QUALIFIED -> left.rewardedUsers.compareTo(right.rewardedUsers)
AdminReferralSort.CREDITS_EARNED -> left.earnedCredits.compareTo(right.earnedCredits)
} * direction
if (primary != 0) primary else left.userId.compareTo(right.userId) * direction
}
}
private fun utcDates(range: AdminStatsRange): List<LocalDate> {
val dates = mutableListOf<LocalDate>()
var date = range.from.atZone(ZoneOffset.UTC).toLocalDate()
@@ -1,6 +1,8 @@
package com.osglab.account.features.admin.users.repositories
import com.osglab.account.config.DatabaseFactory
import com.osglab.account.features.admin.models.AdminSortOrder
import com.osglab.account.features.admin.models.AdminTimeFilter
import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto
import com.osglab.account.features.admin.users.models.AdminUserDetailDto
import com.osglab.account.features.admin.users.models.AdminUserLedgerEntryDto
@@ -14,11 +16,14 @@ import org.jetbrains.exposed.v1.core.SortOrder
import org.jetbrains.exposed.v1.core.Table
import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.core.greater
import org.jetbrains.exposed.v1.core.greaterEq
import org.jetbrains.exposed.v1.core.inList
import org.jetbrains.exposed.v1.core.less
import org.jetbrains.exposed.v1.core.like
import org.jetbrains.exposed.v1.core.or
import org.jetbrains.exposed.v1.javatime.timestamp
import org.jetbrains.exposed.v1.jdbc.andWhere
import org.jetbrains.exposed.v1.jdbc.selectAll
import java.time.Instant
import java.util.UUID
@@ -33,10 +38,54 @@ data class AdminUserLedgerCursor(
val ledgerEntryId: UUID,
)
interface AdminUsersRepository {
suspend fun list(limit: Int, cursor: AdminUserCursor?): List<AdminUserSummaryDto>
enum class AdminUserStatus {
ACTIVE,
SUSPENDED,
}
suspend fun findByIdSuffix(suffix: String, limit: Int): List<AdminUserSummaryDto>
data class AdminUserListQuery(
val time: AdminTimeFilter = AdminTimeFilter(),
val status: AdminUserStatus? = null,
val order: AdminSortOrder = AdminSortOrder.DESC,
)
enum class AdminLedgerType(
internal val entryTypes: Set<LedgerEntryType>,
) {
RESERVE(setOf(LedgerEntryType.USAGE_RESERVE)),
SETTLE(setOf(LedgerEntryType.USAGE_SETTLE)),
REFUND(setOf(LedgerEntryType.USAGE_RELEASE, LedgerEntryType.USAGE_REFUND)),
GRANT(
setOf(
LedgerEntryType.SIGNUP_TRIAL,
LedgerEntryType.MANUAL_GRANT,
LedgerEntryType.REFERRAL_INVITER,
LedgerEntryType.REFERRAL_INVITEE,
LedgerEntryType.STOREKIT_PURCHASE,
LedgerEntryType.SUBSCRIPTION_GRANT,
),
),
ADJUSTMENT(emptySet()),
}
data class AdminLedgerQuery(
val time: AdminTimeFilter = AdminTimeFilter(),
val type: AdminLedgerType? = null,
val order: AdminSortOrder = AdminSortOrder.DESC,
)
interface AdminUsersRepository {
suspend fun list(
limit: Int,
cursor: AdminUserCursor?,
query: AdminUserListQuery,
): List<AdminUserSummaryDto>
suspend fun findByIdSuffix(
suffix: String,
limit: Int,
query: AdminUserListQuery,
): List<AdminUserSummaryDto>
suspend fun exists(userId: UUID): Boolean
@@ -46,11 +95,13 @@ interface AdminUsersRepository {
userId: UUID,
limit: Int,
cursor: AdminUserLedgerCursor?,
query: AdminLedgerQuery,
): List<AdminUserLedgerEntryDto>
suspend fun listLatestLedger(
limit: Int,
cursor: AdminUserLedgerCursor?,
query: AdminLedgerQuery,
): List<AdminUserLedgerEntryDto>
}
@@ -60,21 +111,43 @@ class ExposedAdminUsersRepository(
override suspend fun list(
limit: Int,
cursor: AdminUserCursor?,
query: AdminUserListQuery,
): List<AdminUserSummaryDto> = databaseFactory.query {
val query = AdminUsersAccountsTable.selectAll()
if (cursor != null) {
query.where {
(AdminUsersAccountsTable.createdAt less cursor.createdAt) or
(
(AdminUsersAccountsTable.createdAt eq cursor.createdAt) and
(AdminUsersAccountsTable.id less cursor.userId.toString())
)
val statement = AdminUsersAccountsTable.selectAll()
query.time.from?.let { from ->
statement.andWhere { AdminUsersAccountsTable.createdAt greaterEq from }
}
query.time.until?.let { until ->
statement.andWhere { AdminUsersAccountsTable.createdAt less until }
}
query.status?.let { status ->
statement.andWhere {
AdminUsersAccountsTable.antiAbuseRestricted eq
(status == AdminUserStatus.SUSPENDED)
}
}
val accountRows = query
if (cursor != null) {
statement.andWhere {
if (query.order == AdminSortOrder.ASC) {
(AdminUsersAccountsTable.createdAt greater cursor.createdAt) or
(
(AdminUsersAccountsTable.createdAt eq cursor.createdAt) and
(AdminUsersAccountsTable.id greater cursor.userId.toString())
)
} else {
(AdminUsersAccountsTable.createdAt less cursor.createdAt) or
(
(AdminUsersAccountsTable.createdAt eq cursor.createdAt) and
(AdminUsersAccountsTable.id less cursor.userId.toString())
)
}
}
}
val sortOrder = query.order.toExposedSortOrder()
val accountRows = statement
.orderBy(
AdminUsersAccountsTable.createdAt to SortOrder.DESC,
AdminUsersAccountsTable.id to SortOrder.DESC,
AdminUsersAccountsTable.createdAt to sortOrder,
AdminUsersAccountsTable.id to sortOrder,
)
.limit(limit)
.toList()
@@ -85,12 +158,27 @@ class ExposedAdminUsersRepository(
override suspend fun findByIdSuffix(
suffix: String,
limit: Int,
query: AdminUserListQuery,
): List<AdminUserSummaryDto> = databaseFactory.query {
val accountRows = AdminUsersAccountsTable.selectAll()
val statement = AdminUsersAccountsTable.selectAll()
.where { AdminUsersAccountsTable.id like "%$suffix" }
query.time.from?.let { from ->
statement.andWhere { AdminUsersAccountsTable.createdAt greaterEq from }
}
query.time.until?.let { until ->
statement.andWhere { AdminUsersAccountsTable.createdAt less until }
}
query.status?.let { status ->
statement.andWhere {
AdminUsersAccountsTable.antiAbuseRestricted eq
(status == AdminUserStatus.SUSPENDED)
}
}
val sortOrder = query.order.toExposedSortOrder()
val accountRows = statement
.orderBy(
AdminUsersAccountsTable.createdAt to SortOrder.DESC,
AdminUsersAccountsTable.id to SortOrder.DESC,
AdminUsersAccountsTable.createdAt to sortOrder,
AdminUsersAccountsTable.id to sortOrder,
)
.limit(limit)
.toList()
@@ -155,25 +243,41 @@ class ExposedAdminUsersRepository(
userId: UUID,
limit: Int,
cursor: AdminUserLedgerCursor?,
query: AdminLedgerQuery,
): List<AdminUserLedgerEntryDto> = databaseFactory.query {
val query = AdminUsersCreditLedgerTable.selectAll()
if (cursor == null) {
query.where { AdminUsersCreditLedgerTable.userId eq userId.toString() }
} else {
query.where {
(AdminUsersCreditLedgerTable.userId eq userId.toString()) and
(
(AdminUsersCreditLedgerTable.createdAt less cursor.createdAt) or
(
(AdminUsersCreditLedgerTable.createdAt eq cursor.createdAt) and
(AdminUsersCreditLedgerTable.id less cursor.ledgerEntryId.toString())
)
)
if (query.type == AdminLedgerType.ADJUSTMENT) return@query emptyList()
val statement = AdminUsersCreditLedgerTable.selectAll()
.where { AdminUsersCreditLedgerTable.userId eq userId.toString() }
query.time.from?.let { from ->
statement.andWhere { AdminUsersCreditLedgerTable.createdAt greaterEq from }
}
query.time.until?.let { until ->
statement.andWhere { AdminUsersCreditLedgerTable.createdAt less until }
}
query.type?.let { type ->
statement.andWhere { AdminUsersCreditLedgerTable.entryType inList type.entryTypes }
}
if (cursor != null) {
statement.andWhere {
if (query.order == AdminSortOrder.ASC) {
(AdminUsersCreditLedgerTable.createdAt greater cursor.createdAt) or
(
(AdminUsersCreditLedgerTable.createdAt eq cursor.createdAt) and
(AdminUsersCreditLedgerTable.id greater cursor.ledgerEntryId.toString())
)
} else {
(AdminUsersCreditLedgerTable.createdAt less cursor.createdAt) or
(
(AdminUsersCreditLedgerTable.createdAt eq cursor.createdAt) and
(AdminUsersCreditLedgerTable.id less cursor.ledgerEntryId.toString())
)
}
}
}
val ledger = query.orderBy(
AdminUsersCreditLedgerTable.createdAt to SortOrder.DESC,
AdminUsersCreditLedgerTable.id to SortOrder.DESC,
val sortOrder = query.order.toExposedSortOrder()
val ledger = statement.orderBy(
AdminUsersCreditLedgerTable.createdAt to sortOrder,
AdminUsersCreditLedgerTable.id to sortOrder,
)
.limit(limit)
.map(ResultRow::toUserLedgerRow)
@@ -184,20 +288,40 @@ class ExposedAdminUsersRepository(
override suspend fun listLatestLedger(
limit: Int,
cursor: AdminUserLedgerCursor?,
query: AdminLedgerQuery,
): List<AdminUserLedgerEntryDto> = databaseFactory.query {
val query = AdminUsersCreditLedgerTable.selectAll()
if (query.type == AdminLedgerType.ADJUSTMENT) return@query emptyList()
val statement = AdminUsersCreditLedgerTable.selectAll()
query.time.from?.let { from ->
statement.andWhere { AdminUsersCreditLedgerTable.createdAt greaterEq from }
}
query.time.until?.let { until ->
statement.andWhere { AdminUsersCreditLedgerTable.createdAt less until }
}
query.type?.let { type ->
statement.andWhere { AdminUsersCreditLedgerTable.entryType inList type.entryTypes }
}
if (cursor != null) {
query.where {
(AdminUsersCreditLedgerTable.createdAt less cursor.createdAt) or
(
(AdminUsersCreditLedgerTable.createdAt eq cursor.createdAt) and
(AdminUsersCreditLedgerTable.id less cursor.ledgerEntryId.toString())
)
statement.andWhere {
if (query.order == AdminSortOrder.ASC) {
(AdminUsersCreditLedgerTable.createdAt greater cursor.createdAt) or
(
(AdminUsersCreditLedgerTable.createdAt eq cursor.createdAt) and
(AdminUsersCreditLedgerTable.id greater cursor.ledgerEntryId.toString())
)
} else {
(AdminUsersCreditLedgerTable.createdAt less cursor.createdAt) or
(
(AdminUsersCreditLedgerTable.createdAt eq cursor.createdAt) and
(AdminUsersCreditLedgerTable.id less cursor.ledgerEntryId.toString())
)
}
}
}
val ledger = query.orderBy(
AdminUsersCreditLedgerTable.createdAt to SortOrder.DESC,
AdminUsersCreditLedgerTable.id to SortOrder.DESC,
val sortOrder = query.order.toExposedSortOrder()
val ledger = statement.orderBy(
AdminUsersCreditLedgerTable.createdAt to sortOrder,
AdminUsersCreditLedgerTable.id to sortOrder,
)
.limit(limit)
.map(ResultRow::toUserLedgerRow)
@@ -413,3 +537,6 @@ private fun ResultRow.toUserReferralBindingRow() = UserReferralBindingRow(
private inline fun <T> Iterable<T>.exactSumOf(value: (T) -> Long): Long =
fold(0L) { total, item -> Math.addExact(total, value(item)) }
private fun AdminSortOrder.toExposedSortOrder(): SortOrder =
if (this == AdminSortOrder.ASC) SortOrder.ASC else SortOrder.DESC
@@ -1,11 +1,15 @@
package com.osglab.account.features.admin.users.services
import com.osglab.account.features.admin.models.AdminSortOrder
import com.osglab.account.features.admin.users.models.AdminUserDetailDto
import com.osglab.account.features.admin.users.models.AdminUserLedgerEntryDto
import com.osglab.account.features.admin.users.models.AdminUserLedgerPageDto
import com.osglab.account.features.admin.users.models.AdminUserPageDto
import com.osglab.account.features.admin.users.repositories.AdminUserCursor
import com.osglab.account.features.admin.users.repositories.AdminLedgerQuery
import com.osglab.account.features.admin.users.repositories.AdminUserLedgerCursor
import com.osglab.account.features.admin.users.repositories.AdminUserListQuery
import com.osglab.account.features.admin.users.repositories.AdminUserStatus
import com.osglab.account.features.admin.users.repositories.AdminUsersRepository
import java.nio.charset.StandardCharsets
import java.time.Instant
@@ -17,18 +21,26 @@ class AdminUserNotFoundException : RuntimeException("Admin user view does not ex
class AdminUsersService(
private val repository: AdminUsersRepository,
) {
suspend fun searchByInternalId(query: String): AdminUserPageDto {
suspend fun searchByInternalId(
query: String,
listQuery: AdminUserListQuery = AdminUserListQuery(),
): AdminUserPageDto {
val normalized = query.trim()
val userId = runCatching { UUID.fromString(normalized) }.getOrNull()
if (userId != null) {
val user = repository.findDetail(userId, ledgerLimit = 1)?.summary
?.takeIf { it.matches(listQuery) }
return AdminUserPageDto(user?.let(::listOf) ?: emptyList(), null)
}
if (!SHORT_INTERNAL_ID.matches(normalized)) {
return AdminUserPageDto(emptyList(), null)
}
return AdminUserPageDto(
items = repository.findByIdSuffix(normalized.lowercase(), limit = MAX_SHORT_ID_MATCHES),
items = repository.findByIdSuffix(
normalized.lowercase(),
limit = MAX_SHORT_ID_MATCHES,
query = listQuery,
),
nextCursor = null,
)
}
@@ -36,10 +48,11 @@ class AdminUsersService(
suspend fun list(
limit: Int = 50,
cursor: String? = null,
query: AdminUserListQuery = AdminUserListQuery(),
): AdminUserPageDto {
require(limit in 1..100) { "User page limit must be between 1 and 100" }
val decodedCursor = cursor?.let(AdminUserCursorCodec::decode)
val results = repository.list(limit + 1, decodedCursor)
val decodedCursor = cursor?.let { AdminUserCursorCodec.decode(it, query.order) }
val results = repository.list(limit + 1, decodedCursor, query)
val hasMore = results.size > limit
val items = results.take(limit)
val nextCursor = if (hasMore) {
@@ -49,6 +62,7 @@ class AdminUsersService(
createdAt = Instant.parse(last.createdAt),
userId = UUID.fromString(last.id),
),
query.order,
)
} else {
null
@@ -68,26 +82,31 @@ class AdminUsersService(
userId: UUID,
limit: Int = 50,
cursor: String? = null,
query: AdminLedgerQuery = AdminLedgerQuery(),
): AdminUserLedgerPageDto {
return ledgerPage(limit, cursor) { pageSize, decodedCursor ->
return ledgerPage(limit, cursor, query) { pageSize, decodedCursor ->
if (!repository.exists(userId)) throw AdminUserNotFoundException()
repository.listLedger(userId, pageSize, decodedCursor)
repository.listLedger(userId, pageSize, decodedCursor, query)
}
}
suspend fun latestLedger(
limit: Int = 100,
cursor: String? = null,
query: AdminLedgerQuery = AdminLedgerQuery(),
): AdminUserLedgerPageDto =
ledgerPage(limit, cursor, repository::listLatestLedger)
ledgerPage(limit, cursor, query) { pageSize, decodedCursor ->
repository.listLatestLedger(pageSize, decodedCursor, query)
}
private suspend fun ledgerPage(
limit: Int,
cursor: String?,
query: AdminLedgerQuery,
load: suspend (Int, AdminUserLedgerCursor?) -> List<AdminUserLedgerEntryDto>,
): AdminUserLedgerPageDto {
require(limit in 1..100) { "Ledger page limit must be between 1 and 100" }
val decodedCursor = cursor?.let(AdminUserLedgerCursorCodec::decode)
val decodedCursor = cursor?.let { AdminUserLedgerCursorCodec.decode(it, query.order) }
val results = load(limit + 1, decodedCursor)
val hasMore = results.size > limit
val items = results.take(limit)
@@ -98,6 +117,7 @@ class AdminUsersService(
createdAt = Instant.parse(last.createdAt),
ledgerEntryId = UUID.fromString(last.id),
),
query.order,
)
} else {
null
@@ -109,14 +129,27 @@ class AdminUsersService(
private val SHORT_INTERNAL_ID = Regex("^[A-Fa-f0-9]{8}$")
private const val MAX_SHORT_ID_MATCHES = 100
private fun com.osglab.account.features.admin.users.models.AdminUserSummaryDto.matches(
query: AdminUserListQuery,
): Boolean {
val createdAt = Instant.parse(createdAt)
return query.time.from?.let { createdAt >= it } != false &&
query.time.until?.let { createdAt < it } != false &&
when (query.status) {
AdminUserStatus.ACTIVE -> !antiAbuseRestricted
AdminUserStatus.SUSPENDED -> antiAbuseRestricted
null -> true
}
}
internal object AdminUserCursorCodec {
fun encode(cursor: AdminUserCursor): String {
val value = "${cursor.createdAt}|${cursor.userId}"
fun encode(cursor: AdminUserCursor, order: AdminSortOrder): String {
val value = "v1|${order.name}|${cursor.createdAt}|${cursor.userId}"
return Base64.getUrlEncoder().withoutPadding()
.encodeToString(value.toByteArray(StandardCharsets.UTF_8))
}
fun decode(value: String): AdminUserCursor {
fun decode(value: String, expectedOrder: AdminSortOrder): AdminUserCursor {
require(value.length in 1..256) { "User cursor is invalid" }
return try {
val decoded = String(
@@ -124,10 +157,12 @@ internal object AdminUserCursorCodec {
StandardCharsets.UTF_8,
)
val parts = decoded.split('|')
require(parts.size == 2)
require(parts.size == 4)
require(parts[0] == "v1")
require(parts[1] == expectedOrder.name)
AdminUserCursor(
createdAt = Instant.parse(parts[0]),
userId = UUID.fromString(parts[1]),
createdAt = Instant.parse(parts[2]),
userId = UUID.fromString(parts[3]),
)
} catch (failure: IllegalArgumentException) {
throw IllegalArgumentException("User cursor is invalid", failure)
@@ -138,13 +173,13 @@ internal object AdminUserCursorCodec {
internal object AdminUserLedgerCursorCodec {
private const val INVALID_CURSOR_MESSAGE = "User ledger cursor is invalid"
fun encode(cursor: AdminUserLedgerCursor): String {
val value = "${cursor.createdAt}|${cursor.ledgerEntryId}"
fun encode(cursor: AdminUserLedgerCursor, order: AdminSortOrder): String {
val value = "v1|${order.name}|${cursor.createdAt}|${cursor.ledgerEntryId}"
return Base64.getUrlEncoder().withoutPadding()
.encodeToString(value.toByteArray(StandardCharsets.UTF_8))
}
fun decode(value: String): AdminUserLedgerCursor {
fun decode(value: String, expectedOrder: AdminSortOrder): AdminUserLedgerCursor {
require(value.length in 1..256) { INVALID_CURSOR_MESSAGE }
return try {
val decoded = String(
@@ -152,10 +187,12 @@ internal object AdminUserLedgerCursorCodec {
StandardCharsets.UTF_8,
)
val parts = decoded.split('|')
require(parts.size == 2)
require(parts.size == 4)
require(parts[0] == "v1")
require(parts[1] == expectedOrder.name)
AdminUserLedgerCursor(
createdAt = Instant.parse(parts[0]),
ledgerEntryId = UUID.fromString(parts[1]),
createdAt = Instant.parse(parts[2]),
ledgerEntryId = UUID.fromString(parts[3]),
)
} catch (failure: IllegalArgumentException) {
throw IllegalArgumentException(INVALID_CURSOR_MESSAGE, failure)