Add admin dashboard filtering and sorting
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:
@@ -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,
|
||||
)
|
||||
+149
-22
@@ -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)
|
||||
|
||||
+39
-12
@@ -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),
|
||||
)
|
||||
|
||||
+29
-1
@@ -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()
|
||||
|
||||
+170
-43
@@ -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
|
||||
|
||||
+57
-20
@@ -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)
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
CREATE INDEX idx_accounts_restricted_created_id
|
||||
ON accounts (anti_abuse_restricted, created_at, id);
|
||||
|
||||
CREATE INDEX idx_credit_ledger_type_created_id
|
||||
ON credit_ledger (entry_type, created_at, id);
|
||||
|
||||
CREATE INDEX idx_credit_ledger_user_type_created_id
|
||||
ON credit_ledger (user_id, entry_type, created_at, id);
|
||||
|
||||
CREATE INDEX idx_admin_audit_action_outcome_occurred_id
|
||||
ON admin_audit_log (action, outcome, occurred_at, id);
|
||||
|
||||
CREATE INDEX idx_admin_audit_outcome_occurred_id
|
||||
ON admin_audit_log (outcome, occurred_at, id);
|
||||
|
||||
CREATE INDEX idx_admin_operators_created_id
|
||||
ON admin_operators (created_at, id);
|
||||
|
||||
CREATE INDEX idx_admin_operators_role_status_created_id
|
||||
ON admin_operators (role, disabled_at, created_at, id);
|
||||
|
||||
CREATE INDEX idx_admin_operators_locked_created_id
|
||||
ON admin_operators (locked_until, created_at, id);
|
||||
|
||||
CREATE INDEX idx_admin_operators_last_login_id
|
||||
ON admin_operators (last_login_at, id);
|
||||
@@ -1,14 +1,18 @@
|
||||
package com.osglab.account.features.admin
|
||||
|
||||
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.AdminLockState
|
||||
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
|
||||
@@ -24,6 +28,7 @@ internal class InMemoryAdminRepository(
|
||||
var passwordHash: String = "valid-password-hash",
|
||||
var encryptedTotpSecret: String,
|
||||
var role: AdminRole = AdminRole.SUPER_ADMIN,
|
||||
var lastLoginAt: Instant? = null,
|
||||
) : AdminRepository {
|
||||
private val mutex = Mutex()
|
||||
var lockState = AdminLockState(0, null)
|
||||
@@ -66,19 +71,22 @@ internal class InMemoryAdminRepository(
|
||||
override suspend fun listOperatorsPage(
|
||||
limit: Int,
|
||||
before: AdminOperatorCursor?,
|
||||
query: AdminOperatorQuery,
|
||||
): List<AdminOperatorRecord> = mutex.withLock {
|
||||
require(limit in 1..101)
|
||||
(listOf(baseOperatorRecord()) + additionalOperators.values.map(MutableOperator::toRecord))
|
||||
.asSequence()
|
||||
.filter {
|
||||
before == null ||
|
||||
it.createdAt.isAfter(before.createdAt) ||
|
||||
(it.createdAt == before.createdAt && it.id.toString() > before.id.toString())
|
||||
query.time.from?.let { from -> it.createdAt >= from } != false &&
|
||||
query.time.until?.let { until -> it.createdAt < until } != false &&
|
||||
query.role?.let { role -> it.role == role } != false &&
|
||||
query.enabled?.let { enabled -> (it.disabledAt == null) == enabled } != false &&
|
||||
query.locked?.let { locked ->
|
||||
(it.lockState.lockedUntil?.isAfter(query.now) == true) == locked
|
||||
} != false
|
||||
}
|
||||
.sortedWith(
|
||||
compareBy<AdminOperatorRecord> { it.createdAt }
|
||||
.thenBy { it.id.toString() },
|
||||
)
|
||||
.filter { before == null || operatorAfter(it, before, query) }
|
||||
.sortedWith(operatorComparator(query))
|
||||
.take(limit)
|
||||
.toList()
|
||||
}
|
||||
@@ -307,20 +315,40 @@ internal class InMemoryAdminRepository(
|
||||
override suspend fun listAudit(
|
||||
limit: Int,
|
||||
before: AdminAuditCursor?,
|
||||
query: AdminAuditQuery,
|
||||
): List<AdminAuditRecord> =
|
||||
mutex.withLock {
|
||||
audits.asSequence()
|
||||
.filter {
|
||||
query.time.from?.let { from -> it.occurredAt >= from } != false &&
|
||||
query.time.until?.let { until -> it.occurredAt < until } != false &&
|
||||
query.action?.let { action -> it.action == action } != false &&
|
||||
query.outcome?.let { outcome -> it.outcome == outcome } != false
|
||||
}
|
||||
.filter {
|
||||
before == null ||
|
||||
it.occurredAt.isBefore(before.occurredAt) ||
|
||||
(
|
||||
it.occurredAt == before.occurredAt &&
|
||||
it.id.toString() < before.id.toString()
|
||||
)
|
||||
if (query.order == AdminSortOrder.ASC) {
|
||||
it.occurredAt > before.occurredAt ||
|
||||
(
|
||||
it.occurredAt == before.occurredAt &&
|
||||
it.id.toString() > before.id.toString()
|
||||
)
|
||||
} else {
|
||||
it.occurredAt < before.occurredAt ||
|
||||
(
|
||||
it.occurredAt == before.occurredAt &&
|
||||
it.id.toString() < before.id.toString()
|
||||
)
|
||||
}
|
||||
}
|
||||
.sortedWith(
|
||||
compareByDescending<NewAdminAuditEvent> { it.occurredAt }
|
||||
.thenByDescending { it.id.toString() },
|
||||
if (query.order == AdminSortOrder.ASC) {
|
||||
compareBy<NewAdminAuditEvent> { it.occurredAt }
|
||||
.thenBy { it.id.toString() }
|
||||
} else {
|
||||
compareByDescending<NewAdminAuditEvent> { it.occurredAt }
|
||||
.thenByDescending { it.id.toString() }
|
||||
},
|
||||
)
|
||||
.take(limit)
|
||||
.map {
|
||||
@@ -344,6 +372,27 @@ internal class InMemoryAdminRepository(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun configureOperatorListState(
|
||||
targetId: UUID,
|
||||
disabledAt: Instant? = null,
|
||||
lockState: AdminLockState = AdminLockState(0, null),
|
||||
lastLoginAt: Instant? = null,
|
||||
) {
|
||||
mutex.withLock {
|
||||
if (targetId == operatorId) {
|
||||
this.disabledAt = disabledAt
|
||||
this.lockState = lockState
|
||||
this.lastLoginAt = lastLoginAt
|
||||
} else {
|
||||
additionalOperators.getValue(targetId).apply {
|
||||
this.disabledAt = disabledAt
|
||||
this.lockState = lockState
|
||||
this.lastLoginAt = lastLoginAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun authRecord() = AdminOperatorAuthRecord(
|
||||
id = operatorId,
|
||||
normalizedUsername = username,
|
||||
@@ -360,7 +409,7 @@ internal class InMemoryAdminRepository(
|
||||
role = role,
|
||||
lockState = lockState,
|
||||
disabledAt = disabledAt,
|
||||
lastLoginAt = null,
|
||||
lastLoginAt = lastLoginAt,
|
||||
createdAt = Instant.EPOCH,
|
||||
updatedAt = Instant.EPOCH,
|
||||
)
|
||||
@@ -391,6 +440,7 @@ internal class InMemoryAdminRepository(
|
||||
var lockState: AdminLockState = AdminLockState(0, null),
|
||||
var lastTotpCounter: Long? = null,
|
||||
var disabledAt: Instant? = null,
|
||||
var lastLoginAt: Instant? = null,
|
||||
var updatedAt: Instant = operator.createdAt,
|
||||
) {
|
||||
fun toAuthRecord() = AdminOperatorAuthRecord(
|
||||
@@ -409,7 +459,7 @@ internal class InMemoryAdminRepository(
|
||||
role = operator.role,
|
||||
lockState = lockState,
|
||||
disabledAt = disabledAt,
|
||||
lastLoginAt = null,
|
||||
lastLoginAt = lastLoginAt,
|
||||
createdAt = operator.createdAt,
|
||||
updatedAt = updatedAt,
|
||||
)
|
||||
@@ -424,3 +474,64 @@ private fun NewAdminAuditEvent.forResult(result: AdminOperatorMutationResult): N
|
||||
com.osglab.account.features.admin.models.AdminAuditOutcome.DENIED
|
||||
},
|
||||
)
|
||||
|
||||
private fun operatorComparator(query: AdminOperatorQuery): Comparator<AdminOperatorRecord> =
|
||||
Comparator { left, right ->
|
||||
val primary = when (query.sort) {
|
||||
AdminOperatorSort.CREATED_AT ->
|
||||
orderedComparison(left.createdAt.compareTo(right.createdAt), query.order)
|
||||
AdminOperatorSort.USERNAME ->
|
||||
orderedComparison(left.normalizedUsername.compareTo(right.normalizedUsername), query.order)
|
||||
AdminOperatorSort.LAST_LOGIN_AT -> compareNullableLast(
|
||||
left.lastLoginAt,
|
||||
right.lastLoginAt,
|
||||
query.order,
|
||||
)
|
||||
}
|
||||
if (primary != 0) {
|
||||
primary
|
||||
} else {
|
||||
orderedComparison(left.id.toString().compareTo(right.id.toString()), query.order)
|
||||
}
|
||||
}
|
||||
|
||||
private fun operatorAfter(
|
||||
record: AdminOperatorRecord,
|
||||
cursor: AdminOperatorCursor,
|
||||
query: AdminOperatorQuery,
|
||||
): Boolean {
|
||||
val primary = when (query.sort) {
|
||||
AdminOperatorSort.CREATED_AT -> orderedComparison(
|
||||
record.createdAt.compareTo(Instant.parse(requireNotNull(cursor.value))),
|
||||
query.order,
|
||||
)
|
||||
AdminOperatorSort.USERNAME -> orderedComparison(
|
||||
record.normalizedUsername.compareTo(requireNotNull(cursor.value)),
|
||||
query.order,
|
||||
)
|
||||
AdminOperatorSort.LAST_LOGIN_AT -> compareNullableLast(
|
||||
record.lastLoginAt,
|
||||
cursor.value?.let(Instant::parse),
|
||||
query.order,
|
||||
)
|
||||
}
|
||||
return primary > 0 ||
|
||||
(
|
||||
primary == 0 &&
|
||||
orderedComparison(record.id.toString().compareTo(cursor.id.toString()), query.order) > 0
|
||||
)
|
||||
}
|
||||
|
||||
private fun <T : Comparable<T>> compareNullableLast(
|
||||
left: T?,
|
||||
right: T?,
|
||||
order: AdminSortOrder,
|
||||
): Int = when {
|
||||
left == null && right == null -> 0
|
||||
left == null -> 1
|
||||
right == null -> -1
|
||||
else -> orderedComparison(left.compareTo(right), order)
|
||||
}
|
||||
|
||||
private fun orderedComparison(value: Int, order: AdminSortOrder): Int =
|
||||
if (order == AdminSortOrder.ASC) value else -value
|
||||
|
||||
+85
@@ -4,12 +4,18 @@ import com.osglab.account.config.DatabaseConfig
|
||||
import com.osglab.account.config.DatabaseFactory
|
||||
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.AdminOperatorQuery
|
||||
import com.osglab.account.features.admin.models.AdminOperatorSort
|
||||
import com.osglab.account.features.admin.models.AdminOperatorMutationResult
|
||||
import com.osglab.account.features.admin.models.AdminRole
|
||||
import com.osglab.account.features.admin.models.AdminSortOrder
|
||||
import com.osglab.account.features.admin.models.AdminTimeFilter
|
||||
import com.osglab.account.features.admin.models.NewAdminAuditEvent
|
||||
import com.osglab.account.features.admin.models.NewAdminOperator
|
||||
import com.osglab.account.features.admin.models.NewAdminSession
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.collections.shouldContainExactly
|
||||
import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder
|
||||
import io.kotest.matchers.shouldBe
|
||||
import kotlinx.coroutines.async
|
||||
@@ -175,6 +181,85 @@ class AdminOperatorRepositoryIntegrationTest : FunSpec({
|
||||
(repository.findActiveSessionByTokenHash(activeToken, now) != null) shouldBe true
|
||||
}
|
||||
}
|
||||
|
||||
test("MySQL list queries preserve filters keyset order and null-last semantics") {
|
||||
withAdminRepositories { repository, _ ->
|
||||
val from = Instant.parse("2026-08-17T00:00:00Z")
|
||||
val until = from.plusSeconds(120)
|
||||
val neverLoggedIn = UUID.fromString("10000000-0000-0000-0000-000000000001")
|
||||
val earlier = UUID.fromString("20000000-0000-0000-0000-000000000002")
|
||||
val later = UUID.fromString("30000000-0000-0000-0000-000000000003")
|
||||
listOf(neverLoggedIn, earlier, later).forEachIndexed { index, id ->
|
||||
repository.createOperatorIfAbsent(
|
||||
newOperator(id, "query-operator-$index", AdminRole.SUPPORT, from),
|
||||
)
|
||||
}
|
||||
listOf(earlier to from.plusSeconds(10), later to from.plusSeconds(20)).forEach {
|
||||
(operatorId, loginAt) ->
|
||||
repository.createSessionIfTotpCounterFresh(
|
||||
session = NewAdminSession(
|
||||
id = UUID.randomUUID(),
|
||||
operatorId = operatorId,
|
||||
tokenHash = operatorId.toString().replace("-", "").padEnd(64, '0'),
|
||||
csrfTokenHash = operatorId.toString().replace("-", "").padEnd(64, 'f'),
|
||||
createdAt = loginAt,
|
||||
expiresAt = loginAt.plusSeconds(60),
|
||||
),
|
||||
totpCounter = 1,
|
||||
now = loginAt,
|
||||
auditEvent = audit(operatorId, AdminAuditAction.LOGIN_SUCCEEDED, operatorId, loginAt),
|
||||
)
|
||||
}
|
||||
|
||||
val operators = repository.listOperatorsPage(
|
||||
limit = 10,
|
||||
query = AdminOperatorQuery(
|
||||
time = AdminTimeFilter(from, until),
|
||||
role = AdminRole.SUPPORT,
|
||||
sort = AdminOperatorSort.LAST_LOGIN_AT,
|
||||
order = AdminSortOrder.DESC,
|
||||
now = until,
|
||||
),
|
||||
)
|
||||
operators.map { it.id } shouldContainExactly listOf(later, earlier, neverLoggedIn)
|
||||
|
||||
val lowerAuditId = UUID.fromString("40000000-0000-0000-0000-000000000004")
|
||||
val higherAuditId = UUID.fromString("50000000-0000-0000-0000-000000000005")
|
||||
listOf(
|
||||
NewAdminAuditEvent(
|
||||
id = higherAuditId,
|
||||
actorOperatorId = null,
|
||||
action = AdminAuditAction.LOGIN_FAILED,
|
||||
outcome = AdminAuditOutcome.DENIED,
|
||||
occurredAt = from,
|
||||
),
|
||||
NewAdminAuditEvent(
|
||||
id = lowerAuditId,
|
||||
actorOperatorId = null,
|
||||
action = AdminAuditAction.LOGIN_FAILED,
|
||||
outcome = AdminAuditOutcome.DENIED,
|
||||
occurredAt = from,
|
||||
),
|
||||
NewAdminAuditEvent(
|
||||
actorOperatorId = null,
|
||||
action = AdminAuditAction.LOGIN_FAILED,
|
||||
outcome = AdminAuditOutcome.DENIED,
|
||||
occurredAt = until,
|
||||
),
|
||||
).forEach { repository.appendAudit(it) }
|
||||
val audits = repository.listAudit(
|
||||
limit = 10,
|
||||
query = AdminAuditQuery(
|
||||
time = AdminTimeFilter(from, until),
|
||||
action = AdminAuditAction.LOGIN_FAILED,
|
||||
outcome = AdminAuditOutcome.DENIED,
|
||||
order = AdminSortOrder.ASC,
|
||||
),
|
||||
)
|
||||
|
||||
audits.map { it.id } shouldContainExactly listOf(lowerAuditId, higherAuditId)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
private suspend fun withAdminRepositories(
|
||||
|
||||
@@ -188,7 +188,7 @@ class AdminRoutesTest {
|
||||
fun `operator list maps non-super authorization to stable forbidden response`() = testApplication {
|
||||
val sessionService = sessionFixture(AdminRole.SUPPORT)
|
||||
val operatorService = mockk<AdminOperatorService>()
|
||||
coEvery { operatorService.listPage(any(), any(), any()) } throws
|
||||
coEvery { operatorService.listPage(any(), any(), any(), any()) } throws
|
||||
AdminOperatorException(AdminOperatorErrorCode.INSUFFICIENT_PERMISSION)
|
||||
application {
|
||||
installAdminTestRoutes(
|
||||
@@ -259,6 +259,34 @@ class AdminRoutesTest {
|
||||
response.bodyAsText() shouldContain """"usageType":"hotword""""
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `admin list routes reject non-whitelisted filters and invalid ranges`() = testApplication {
|
||||
application {
|
||||
installAdminTestRoutes(
|
||||
sessionService = sessionFixture(AdminRole.SUPER_ADMIN),
|
||||
)
|
||||
}
|
||||
val invalidPaths = listOf(
|
||||
"/v1/admin/users?from=2026-08-20T00:00:00Z&until=2026-08-20T00:00:00Z",
|
||||
"/v1/admin/credits/ledger?type=unknown",
|
||||
"/v1/admin/operators?enabled=1",
|
||||
"/v1/admin/audit?action=NOT_AN_ACTION",
|
||||
"/v1/admin/referrals?range=30d&limit=101",
|
||||
"/v1/admin/users?unexpected=value",
|
||||
"/v1/admin/users?from=2026-08-20T08:00:00%2B08:00",
|
||||
)
|
||||
|
||||
invalidPaths.forEach { path ->
|
||||
val response = client.get(path) {
|
||||
header("X-OSG-mTLS-Verified", "SUCCESS")
|
||||
header(HttpHeaders.Cookie, "osg_admin_session=session-token")
|
||||
}
|
||||
|
||||
assertEquals(HttpStatusCode.BadRequest, response.status, path)
|
||||
response.bodyAsText() shouldContain """"code":"VALIDATION_ERROR""""
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `operator creation maps normalized username conflict to 409`() = testApplication {
|
||||
val sessionService = sessionFixture(AdminRole.SUPER_ADMIN)
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
package com.osglab.account.features.admin.services
|
||||
|
||||
import com.osglab.account.features.admin.InMemoryAdminRepository
|
||||
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.AdminAuditRecord
|
||||
import com.osglab.account.features.admin.models.AdminSortOrder
|
||||
import com.osglab.account.features.admin.models.AdminTimeFilter
|
||||
import com.osglab.account.features.admin.models.AdminPrincipal
|
||||
import com.osglab.account.features.admin.models.AdminRole
|
||||
import com.osglab.account.features.admin.models.NewAdminAuditEvent
|
||||
import com.osglab.account.features.admin.repositories.AdminRepository
|
||||
import io.kotest.assertions.throwables.shouldThrow
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
@@ -66,6 +71,54 @@ class AdminAuditServiceTest : FunSpec({
|
||||
service.list(support, null)
|
||||
}.code shouldBe AdminOperatorErrorCode.INSUFFICIENT_PERMISSION
|
||||
}
|
||||
|
||||
test("audit filters use half-open boundaries and ascending ID tie-break") {
|
||||
val repository = InMemoryAdminRepository(encryptedTotpSecret = "encrypted")
|
||||
val actor = superAdministrator().copy(operatorId = repository.operatorId)
|
||||
val from = Instant.parse("2026-08-17T00:00:00Z")
|
||||
val until = from.plusSeconds(60)
|
||||
val lowerId = UUID.fromString("11111111-1111-4111-8111-111111111111")
|
||||
val higherId = UUID.fromString("22222222-2222-4222-8222-222222222222")
|
||||
listOf(
|
||||
auditEvent(lowerId, from, AdminAuditAction.LOGIN_FAILED, AdminAuditOutcome.DENIED),
|
||||
auditEvent(higherId, from, AdminAuditAction.LOGIN_FAILED, AdminAuditOutcome.DENIED),
|
||||
auditEvent(UUID.randomUUID(), from.minusNanos(1), AdminAuditAction.LOGIN_FAILED, AdminAuditOutcome.DENIED),
|
||||
auditEvent(UUID.randomUUID(), until, AdminAuditAction.LOGIN_FAILED, AdminAuditOutcome.DENIED),
|
||||
auditEvent(UUID.randomUUID(), from.plusSeconds(1), AdminAuditAction.LOGIN_SUCCEEDED, AdminAuditOutcome.SUCCESS),
|
||||
).forEach { repository.appendAudit(it) }
|
||||
|
||||
val page = AdminAuditService(repository).list(
|
||||
actor = actor,
|
||||
cursor = null,
|
||||
query = AdminAuditQuery(
|
||||
time = AdminTimeFilter(from, until),
|
||||
action = AdminAuditAction.LOGIN_FAILED,
|
||||
outcome = AdminAuditOutcome.DENIED,
|
||||
order = AdminSortOrder.ASC,
|
||||
),
|
||||
)
|
||||
|
||||
page.items.map { it.record.id } shouldBe listOf(lowerId, higherId)
|
||||
}
|
||||
|
||||
test("audit cursor rejects the opposite order") {
|
||||
val repository = InMemoryAdminRepository(encryptedTotpSecret = "encrypted")
|
||||
val actor = superAdministrator().copy(operatorId = repository.operatorId)
|
||||
listOf(
|
||||
auditEvent(UUID.randomUUID(), Instant.parse("2026-08-17T00:00:02Z")),
|
||||
auditEvent(UUID.randomUUID(), Instant.parse("2026-08-17T00:00:01Z")),
|
||||
).forEach { repository.appendAudit(it) }
|
||||
val service = AdminAuditService(repository)
|
||||
val first = service.list(actor, null, limit = 1)
|
||||
|
||||
shouldThrow<AdminAuditCursorException> {
|
||||
service.list(
|
||||
actor,
|
||||
first.nextCursor,
|
||||
query = AdminAuditQuery(order = AdminSortOrder.ASC),
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
private fun auditRecord(occurredAt: String) = AdminAuditRecord(
|
||||
@@ -79,6 +132,19 @@ private fun auditRecord(occurredAt: String) = AdminAuditRecord(
|
||||
occurredAt = Instant.parse(occurredAt),
|
||||
)
|
||||
|
||||
private fun auditEvent(
|
||||
id: UUID,
|
||||
occurredAt: Instant,
|
||||
action: AdminAuditAction = AdminAuditAction.LOGIN_SUCCEEDED,
|
||||
outcome: AdminAuditOutcome = AdminAuditOutcome.SUCCESS,
|
||||
) = NewAdminAuditEvent(
|
||||
id = id,
|
||||
actorOperatorId = null,
|
||||
action = action,
|
||||
outcome = outcome,
|
||||
occurredAt = occurredAt,
|
||||
)
|
||||
|
||||
private fun superAdministrator() = AdminPrincipal(
|
||||
operatorId = UUID.randomUUID(),
|
||||
sessionId = UUID.randomUUID(),
|
||||
|
||||
+102
@@ -6,9 +6,13 @@ import com.osglab.account.features.admin.models.AdminAuditAction
|
||||
import com.osglab.account.features.admin.models.AdminAuditOutcome
|
||||
import com.osglab.account.features.admin.models.AdminLockState
|
||||
import com.osglab.account.features.admin.models.AdminOperatorRecord
|
||||
import com.osglab.account.features.admin.models.AdminOperatorQuery
|
||||
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.AdminSessionRecord
|
||||
import com.osglab.account.features.admin.models.AdminSortOrder
|
||||
import com.osglab.account.features.admin.models.AdminTimeFilter
|
||||
import com.osglab.account.features.admin.models.NewAdminOperator
|
||||
import com.osglab.account.features.admin.security.AdminPasswordHasher
|
||||
import com.osglab.account.features.admin.security.AdminTotpProvisioning
|
||||
@@ -216,6 +220,104 @@ class AdminOperatorServiceTest : FunSpec({
|
||||
fixture.service.listPage(fixture.owner, cursor = "not-a-cursor")
|
||||
}
|
||||
}
|
||||
|
||||
test("operator filters apply half-open time role enabled and current lock state") {
|
||||
val fixture = operatorFixture()
|
||||
val matching = fixture.seedOperator("locked-support", AdminRole.SUPPORT)
|
||||
val expired = fixture.seedOperator("expired-support", AdminRole.SUPPORT)
|
||||
val disabled = fixture.seedOperator("disabled-support", AdminRole.SUPPORT)
|
||||
fixture.repository.configureOperatorListState(
|
||||
matching,
|
||||
lockState = AdminLockState(2, fixture.clock.instant().plusSeconds(60)),
|
||||
)
|
||||
fixture.repository.configureOperatorListState(
|
||||
expired,
|
||||
lockState = AdminLockState(2, fixture.clock.instant()),
|
||||
)
|
||||
fixture.repository.configureOperatorListState(
|
||||
disabled,
|
||||
disabledAt = fixture.clock.instant(),
|
||||
lockState = AdminLockState(2, fixture.clock.instant().plusSeconds(60)),
|
||||
)
|
||||
|
||||
val page = fixture.service.listPage(
|
||||
actor = fixture.owner,
|
||||
cursor = null,
|
||||
query = AdminOperatorQuery(
|
||||
time = AdminTimeFilter(
|
||||
fixture.clock.instant(),
|
||||
fixture.clock.instant().plusNanos(1),
|
||||
),
|
||||
role = AdminRole.SUPPORT,
|
||||
enabled = true,
|
||||
locked = true,
|
||||
now = fixture.clock.instant(),
|
||||
),
|
||||
)
|
||||
|
||||
page.items.map(AdminOperatorRecord::id) shouldBe listOf(matching)
|
||||
}
|
||||
|
||||
test("last login sorting keeps null values last in both directions") {
|
||||
val fixture = operatorFixture()
|
||||
val earlier = fixture.seedOperator("earlier-login", AdminRole.SUPPORT)
|
||||
val later = fixture.seedOperator("later-login", AdminRole.SUPPORT)
|
||||
fixture.repository.configureOperatorListState(
|
||||
earlier,
|
||||
lastLoginAt = fixture.clock.instant().minusSeconds(60),
|
||||
)
|
||||
fixture.repository.configureOperatorListState(
|
||||
later,
|
||||
lastLoginAt = fixture.clock.instant(),
|
||||
)
|
||||
|
||||
val ascending = fixture.service.listPage(
|
||||
fixture.owner,
|
||||
cursor = null,
|
||||
query = AdminOperatorQuery(
|
||||
sort = AdminOperatorSort.LAST_LOGIN_AT,
|
||||
order = AdminSortOrder.ASC,
|
||||
now = fixture.clock.instant(),
|
||||
),
|
||||
)
|
||||
val descending = fixture.service.listPage(
|
||||
fixture.owner,
|
||||
cursor = null,
|
||||
query = AdminOperatorQuery(
|
||||
sort = AdminOperatorSort.LAST_LOGIN_AT,
|
||||
order = AdminSortOrder.DESC,
|
||||
now = fixture.clock.instant(),
|
||||
),
|
||||
)
|
||||
|
||||
ascending.items.map(AdminOperatorRecord::id) shouldBe
|
||||
listOf(earlier, later, fixture.owner.operatorId)
|
||||
descending.items.map(AdminOperatorRecord::id) shouldBe
|
||||
listOf(later, earlier, fixture.owner.operatorId)
|
||||
}
|
||||
|
||||
test("operator cursor rejects a different sort contract") {
|
||||
val fixture = operatorFixture()
|
||||
fixture.seedOperator("cursor-a", AdminRole.SUPPORT)
|
||||
fixture.seedOperator("cursor-b", AdminRole.SUPPORT)
|
||||
val first = fixture.service.listPage(
|
||||
fixture.owner,
|
||||
cursor = null,
|
||||
limit = 1,
|
||||
query = AdminOperatorQuery(now = fixture.clock.instant()),
|
||||
)
|
||||
|
||||
shouldThrow<AdminOperatorCursorException> {
|
||||
fixture.service.listPage(
|
||||
fixture.owner,
|
||||
cursor = first.nextCursor,
|
||||
query = AdminOperatorQuery(
|
||||
sort = AdminOperatorSort.USERNAME,
|
||||
now = fixture.clock.instant(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
private data class OperatorFixture(
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.osglab.account.features.admin.stats
|
||||
|
||||
import com.osglab.account.features.admin.models.AdminSortOrder
|
||||
import com.osglab.account.features.admin.stats.models.AdminOverviewDto
|
||||
import com.osglab.account.features.admin.stats.models.AdminReferralFunnelDto
|
||||
import com.osglab.account.features.admin.stats.models.AdminReferralRankDto
|
||||
import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto
|
||||
import com.osglab.account.features.admin.stats.repositories.AdminStatsAggregates
|
||||
import com.osglab.account.features.admin.stats.repositories.AdminStatsRange
|
||||
@@ -11,6 +13,7 @@ import com.osglab.account.features.admin.stats.repositories.ReferralBindingAggre
|
||||
import com.osglab.account.features.admin.stats.repositories.assembleAdminStats
|
||||
import com.osglab.account.features.admin.stats.repositories.toExactLong
|
||||
import com.osglab.account.features.admin.stats.services.AdminStatsService
|
||||
import com.osglab.account.features.admin.stats.services.AdminReferralSort
|
||||
import io.kotest.assertions.throwables.shouldThrow
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.collections.shouldHaveSize
|
||||
@@ -159,4 +162,40 @@ class AdminStatsRepositoryTest : FunSpec({
|
||||
BigDecimal("1.5").toExactLong()
|
||||
}
|
||||
}
|
||||
|
||||
test("referral ranking supports explicit metric order stable ID tie-break and limit") {
|
||||
val snapshot = AdminStatsSnapshot(
|
||||
overview = AdminOverviewDto(0, 0, 0, 0, 0, 0),
|
||||
registrationsByDate = emptyMap(),
|
||||
issuedCreditsByDate = emptyMap(),
|
||||
consumedCreditsByDate = emptyMap(),
|
||||
referralFunnel = AdminReferralFunnelDto(0, 0, 0, 0, 0),
|
||||
referralRanking = listOf(
|
||||
AdminReferralRankDto("user-c", 2, 1, 20),
|
||||
AdminReferralRankDto("user-a", 3, 1, 20),
|
||||
AdminReferralRankDto("user-b", 1, 2, 10),
|
||||
),
|
||||
usage = emptyList(),
|
||||
)
|
||||
val service = AdminStatsService(AdminStatsRepository { snapshot })
|
||||
|
||||
val descending = service.get(
|
||||
from,
|
||||
until,
|
||||
referralRankLimit = 2,
|
||||
referralSort = AdminReferralSort.CREDITS_EARNED,
|
||||
referralOrder = AdminSortOrder.DESC,
|
||||
)
|
||||
val ascending = service.get(
|
||||
from,
|
||||
until,
|
||||
referralSort = AdminReferralSort.QUALIFIED,
|
||||
referralOrder = AdminSortOrder.ASC,
|
||||
)
|
||||
|
||||
descending.referralRanking.map(AdminReferralRankDto::userId) shouldBe
|
||||
listOf("user-c", "user-a")
|
||||
ascending.referralRanking.map(AdminReferralRankDto::userId) shouldBe
|
||||
listOf("user-a", "user-c", "user-b")
|
||||
}
|
||||
})
|
||||
|
||||
@@ -5,8 +5,14 @@ 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.stats.models.AdminUsageAggregateDto
|
||||
import com.osglab.account.features.admin.models.AdminSortOrder
|
||||
import com.osglab.account.features.admin.models.AdminTimeFilter
|
||||
import com.osglab.account.features.admin.users.repositories.AdminLedgerType
|
||||
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 com.osglab.account.features.admin.users.services.AdminUserNotFoundException
|
||||
import com.osglab.account.features.admin.users.services.AdminUsersService
|
||||
@@ -223,6 +229,112 @@ class AdminUsersServiceTest : FunSpec({
|
||||
service.detail(UUID.randomUUID())
|
||||
}
|
||||
}
|
||||
|
||||
test("user filters use a UTC half-open range and ascending ID tie-break") {
|
||||
val boundary = Instant.parse("2026-08-15T00:00:00Z")
|
||||
val until = Instant.parse("2026-08-16T00:00:00Z")
|
||||
val lowerId = UUID.fromString("11111111-1111-4111-8111-111111111111")
|
||||
val higherId = UUID.fromString("22222222-2222-4222-8222-222222222222")
|
||||
val service = AdminUsersService(
|
||||
PagingUsersRepository(
|
||||
listOf(
|
||||
summary(higherId, boundary, restricted = true),
|
||||
summary(lowerId, boundary, restricted = true),
|
||||
summary(UUID.randomUUID(), boundary.minusNanos(1), restricted = true),
|
||||
summary(UUID.randomUUID(), until, restricted = true),
|
||||
summary(UUID.randomUUID(), boundary.plusSeconds(1), restricted = false),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val page = service.list(
|
||||
query = AdminUserListQuery(
|
||||
time = AdminTimeFilter(boundary, until),
|
||||
status = AdminUserStatus.SUSPENDED,
|
||||
order = AdminSortOrder.ASC,
|
||||
),
|
||||
)
|
||||
|
||||
page.items.map(AdminUserSummaryDto::id) shouldBe
|
||||
listOf(lowerId.toString(), higherId.toString())
|
||||
}
|
||||
|
||||
test("ledger filters type and half-open range in ascending stable order") {
|
||||
val userId = UUID.randomUUID()
|
||||
val from = Instant.parse("2026-08-15T00:00:00Z")
|
||||
val until = from.plusSeconds(60)
|
||||
val lowerId = UUID.fromString("11111111-1111-4111-8111-111111111111")
|
||||
val higherId = UUID.fromString("22222222-2222-4222-8222-222222222222")
|
||||
val entries = listOf(
|
||||
ledgerEntry(higherId, from, userId),
|
||||
ledgerEntry(lowerId, from, userId),
|
||||
ledgerEntry(UUID.randomUUID(), from.minusNanos(1), userId),
|
||||
ledgerEntry(UUID.randomUUID(), until, userId),
|
||||
ledgerEntry(UUID.randomUUID(), from.plusSeconds(1), userId, type = "USAGE_SETTLE"),
|
||||
)
|
||||
val service = AdminUsersService(
|
||||
PagingUsersRepository(emptyList(), ledger = mapOf(userId to entries)),
|
||||
)
|
||||
|
||||
val page = service.ledger(
|
||||
userId = userId,
|
||||
query = AdminLedgerQuery(
|
||||
time = AdminTimeFilter(from, until),
|
||||
type = AdminLedgerType.GRANT,
|
||||
order = AdminSortOrder.ASC,
|
||||
),
|
||||
)
|
||||
|
||||
page.items.map(AdminUserLedgerEntryDto::id) shouldBe
|
||||
listOf(lowerId.toString(), higherId.toString())
|
||||
}
|
||||
|
||||
test("versioned user cursor rejects the opposite sort direction") {
|
||||
val service = AdminUsersService(
|
||||
PagingUsersRepository(
|
||||
listOf(
|
||||
summary(UUID.randomUUID(), Instant.parse("2026-08-15T02:00:00Z")),
|
||||
summary(UUID.randomUUID(), Instant.parse("2026-08-15T01:00:00Z")),
|
||||
),
|
||||
),
|
||||
)
|
||||
val first = service.list(limit = 1, query = AdminUserListQuery(order = AdminSortOrder.DESC))
|
||||
|
||||
shouldThrow<IllegalArgumentException> {
|
||||
service.list(
|
||||
cursor = first.nextCursor.shouldNotBeNull(),
|
||||
query = AdminUserListQuery(order = AdminSortOrder.ASC),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
test("versioned ledger cursor rejects the opposite sort direction") {
|
||||
val userId = UUID.randomUUID()
|
||||
val service = AdminUsersService(
|
||||
PagingUsersRepository(
|
||||
emptyList(),
|
||||
ledger = mapOf(
|
||||
userId to listOf(
|
||||
ledgerEntry(UUID.randomUUID(), Instant.parse("2026-08-15T02:00:00Z"), userId),
|
||||
ledgerEntry(UUID.randomUUID(), Instant.parse("2026-08-15T01:00:00Z"), userId),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
val first = service.ledger(
|
||||
userId,
|
||||
limit = 1,
|
||||
query = AdminLedgerQuery(order = AdminSortOrder.DESC),
|
||||
)
|
||||
|
||||
shouldThrow<IllegalArgumentException> {
|
||||
service.ledger(
|
||||
userId,
|
||||
cursor = first.nextCursor.shouldNotBeNull(),
|
||||
query = AdminLedgerQuery(order = AdminSortOrder.ASC),
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
private class PagingUsersRepository(
|
||||
@@ -233,15 +345,24 @@ private class PagingUsersRepository(
|
||||
override suspend fun list(
|
||||
limit: Int,
|
||||
cursor: AdminUserCursor?,
|
||||
query: AdminUserListQuery,
|
||||
): List<AdminUserSummaryDto> =
|
||||
users.filter {
|
||||
cursor == null ||
|
||||
Instant.parse(it.createdAt) < cursor.createdAt ||
|
||||
(
|
||||
Instant.parse(it.createdAt) == cursor.createdAt &&
|
||||
UUID.fromString(it.id).toString() < cursor.userId.toString()
|
||||
)
|
||||
}.take(limit)
|
||||
users.asSequence()
|
||||
.filter { it.matches(query) }
|
||||
.filter {
|
||||
cursor == null || userAfter(it, cursor, query.order)
|
||||
}
|
||||
.sortedWith(
|
||||
if (query.order == AdminSortOrder.ASC) {
|
||||
compareBy<AdminUserSummaryDto> { Instant.parse(it.createdAt) }
|
||||
.thenBy(AdminUserSummaryDto::id)
|
||||
} else {
|
||||
compareByDescending<AdminUserSummaryDto> { Instant.parse(it.createdAt) }
|
||||
.thenByDescending(AdminUserSummaryDto::id)
|
||||
},
|
||||
)
|
||||
.take(limit)
|
||||
.toList()
|
||||
|
||||
override suspend fun findDetail(
|
||||
userId: UUID,
|
||||
@@ -251,8 +372,11 @@ private class PagingUsersRepository(
|
||||
override suspend fun findByIdSuffix(
|
||||
suffix: String,
|
||||
limit: Int,
|
||||
query: AdminUserListQuery,
|
||||
): List<AdminUserSummaryDto> =
|
||||
users.filter { it.id.endsWith(suffix, ignoreCase = true) }.take(limit)
|
||||
users.filter {
|
||||
it.id.endsWith(suffix, ignoreCase = true) && it.matches(query)
|
||||
}.take(limit)
|
||||
|
||||
override suspend fun exists(userId: UUID): Boolean =
|
||||
userId in details || userId in ledger || users.any { it.id == userId.toString() }
|
||||
@@ -261,48 +385,108 @@ private class PagingUsersRepository(
|
||||
userId: UUID,
|
||||
limit: Int,
|
||||
cursor: AdminUserLedgerCursor?,
|
||||
query: AdminLedgerQuery,
|
||||
): List<AdminUserLedgerEntryDto> =
|
||||
ledger[userId].orEmpty()
|
||||
.filter { it.matches(query) }
|
||||
.filter {
|
||||
val createdAt = Instant.parse(it.createdAt)
|
||||
cursor == null ||
|
||||
createdAt < cursor.createdAt ||
|
||||
(
|
||||
createdAt == cursor.createdAt &&
|
||||
it.id < cursor.ledgerEntryId.toString()
|
||||
)
|
||||
cursor == null || ledgerAfter(it, cursor, query.order)
|
||||
}
|
||||
.sortedWith(
|
||||
compareByDescending<AdminUserLedgerEntryDto> { Instant.parse(it.createdAt) }
|
||||
.thenByDescending(AdminUserLedgerEntryDto::id),
|
||||
ledgerComparator(query.order),
|
||||
)
|
||||
.take(limit)
|
||||
|
||||
override suspend fun listLatestLedger(
|
||||
limit: Int,
|
||||
cursor: AdminUserLedgerCursor?,
|
||||
query: AdminLedgerQuery,
|
||||
): List<AdminUserLedgerEntryDto> =
|
||||
ledger.values.flatten()
|
||||
.filter { it.matches(query) }
|
||||
.filter {
|
||||
val createdAt = Instant.parse(it.createdAt)
|
||||
cursor == null ||
|
||||
createdAt < cursor.createdAt ||
|
||||
(
|
||||
createdAt == cursor.createdAt &&
|
||||
it.id < cursor.ledgerEntryId.toString()
|
||||
)
|
||||
cursor == null || ledgerAfter(it, cursor, query.order)
|
||||
}
|
||||
.sortedWith(
|
||||
compareByDescending<AdminUserLedgerEntryDto> { Instant.parse(it.createdAt) }
|
||||
.thenByDescending(AdminUserLedgerEntryDto::id),
|
||||
ledgerComparator(query.order),
|
||||
)
|
||||
.take(limit)
|
||||
}
|
||||
|
||||
private fun summary(id: UUID, createdAt: Instant) = AdminUserSummaryDto(
|
||||
private fun AdminUserSummaryDto.matches(query: AdminUserListQuery): Boolean {
|
||||
val instant = Instant.parse(createdAt)
|
||||
return query.time.from?.let { instant >= it } != false &&
|
||||
query.time.until?.let { instant < it } != false &&
|
||||
when (query.status) {
|
||||
AdminUserStatus.ACTIVE -> !antiAbuseRestricted
|
||||
AdminUserStatus.SUSPENDED -> antiAbuseRestricted
|
||||
null -> true
|
||||
}
|
||||
}
|
||||
|
||||
private fun userAfter(
|
||||
item: AdminUserSummaryDto,
|
||||
cursor: AdminUserCursor,
|
||||
order: AdminSortOrder,
|
||||
): Boolean {
|
||||
val createdAt = Instant.parse(item.createdAt)
|
||||
return if (order == AdminSortOrder.ASC) {
|
||||
createdAt > cursor.createdAt ||
|
||||
(createdAt == cursor.createdAt && item.id > cursor.userId.toString())
|
||||
} else {
|
||||
createdAt < cursor.createdAt ||
|
||||
(createdAt == cursor.createdAt && item.id < cursor.userId.toString())
|
||||
}
|
||||
}
|
||||
|
||||
private fun AdminUserLedgerEntryDto.matches(query: AdminLedgerQuery): Boolean {
|
||||
val instant = Instant.parse(createdAt)
|
||||
val category = when (type) {
|
||||
"USAGE_RESERVE" -> AdminLedgerType.RESERVE
|
||||
"USAGE_SETTLE" -> AdminLedgerType.SETTLE
|
||||
"USAGE_RELEASE", "USAGE_REFUND" -> AdminLedgerType.REFUND
|
||||
"SIGNUP_TRIAL", "MANUAL_GRANT", "REFERRAL_INVITER", "REFERRAL_INVITEE",
|
||||
"STOREKIT_PURCHASE", "SUBSCRIPTION_GRANT",
|
||||
-> AdminLedgerType.GRANT
|
||||
else -> AdminLedgerType.ADJUSTMENT
|
||||
}
|
||||
return query.time.from?.let { instant >= it } != false &&
|
||||
query.time.until?.let { instant < it } != false &&
|
||||
(query.type == null || query.type == category)
|
||||
}
|
||||
|
||||
private fun ledgerAfter(
|
||||
item: AdminUserLedgerEntryDto,
|
||||
cursor: AdminUserLedgerCursor,
|
||||
order: AdminSortOrder,
|
||||
): Boolean {
|
||||
val createdAt = Instant.parse(item.createdAt)
|
||||
return if (order == AdminSortOrder.ASC) {
|
||||
createdAt > cursor.createdAt ||
|
||||
(createdAt == cursor.createdAt && item.id > cursor.ledgerEntryId.toString())
|
||||
} else {
|
||||
createdAt < cursor.createdAt ||
|
||||
(createdAt == cursor.createdAt && item.id < cursor.ledgerEntryId.toString())
|
||||
}
|
||||
}
|
||||
|
||||
private fun ledgerComparator(order: AdminSortOrder): Comparator<AdminUserLedgerEntryDto> =
|
||||
if (order == AdminSortOrder.ASC) {
|
||||
compareBy<AdminUserLedgerEntryDto> { Instant.parse(it.createdAt) }
|
||||
.thenBy(AdminUserLedgerEntryDto::id)
|
||||
} else {
|
||||
compareByDescending<AdminUserLedgerEntryDto> { Instant.parse(it.createdAt) }
|
||||
.thenByDescending(AdminUserLedgerEntryDto::id)
|
||||
}
|
||||
|
||||
private fun summary(
|
||||
id: UUID,
|
||||
createdAt: Instant,
|
||||
restricted: Boolean = false,
|
||||
) = AdminUserSummaryDto(
|
||||
id = id.toString(),
|
||||
createdAt = createdAt.toString(),
|
||||
antiAbuseRestricted = false,
|
||||
antiAbuseRestricted = restricted,
|
||||
creditBalance = 0,
|
||||
consumedCredits = 0,
|
||||
manualGrantedCredits = 0,
|
||||
@@ -316,10 +500,11 @@ private fun ledgerEntry(
|
||||
id: UUID,
|
||||
createdAt: Instant,
|
||||
userId: UUID = UUID.fromString("11111111-1111-4111-8111-111111111111"),
|
||||
type: String = "MANUAL_GRANT",
|
||||
) = AdminUserLedgerEntryDto(
|
||||
id = id.toString(),
|
||||
userId = userId.toString(),
|
||||
type = "MANUAL_GRANT",
|
||||
type = type,
|
||||
amountDelta = 10,
|
||||
balanceAfter = 10,
|
||||
referenceId = null,
|
||||
|
||||
Reference in New Issue
Block a user