Show latest credit ledger in admin
Expose a paginated global ledger timeline and load it automatically so operators can see recent credit activity without first locating a user.
This commit is contained in:
@@ -237,6 +237,33 @@ fun Route.adminApiRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
get("/credits/ledger") {
|
||||
if (
|
||||
call.requireRole(
|
||||
sessionService,
|
||||
setOf(AdminRole.SUPER_ADMIN, AdminRole.SUPPORT),
|
||||
) == null
|
||||
) return@get
|
||||
val limit = call.pageLimit(maximum = 100, default = 100) ?: run {
|
||||
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
|
||||
return@get
|
||||
}
|
||||
try {
|
||||
val page = usersService.latestLedger(
|
||||
limit = limit,
|
||||
cursor = call.request.queryParameters["cursor"],
|
||||
)
|
||||
call.respond(
|
||||
PageResponse(
|
||||
page.items.map(AdminUserLedgerEntryDto::toLedgerResponse),
|
||||
page.nextCursor,
|
||||
),
|
||||
)
|
||||
} catch (_: IllegalArgumentException) {
|
||||
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
|
||||
}
|
||||
}
|
||||
|
||||
post("/credits/grants") {
|
||||
val principal = call.requireMutationPrincipal(config, sessionService) ?: return@post
|
||||
if (principal.role != AdminRole.SUPER_ADMIN) {
|
||||
@@ -667,6 +694,7 @@ private fun AdminUserDetailDto.toUserDetailResponse(): AdminUserDetailResponse =
|
||||
private fun AdminUserLedgerEntryDto.toLedgerResponse(): AdminLedgerResponse =
|
||||
AdminLedgerResponse(
|
||||
entryId = id,
|
||||
userId = userId,
|
||||
type = when (type) {
|
||||
"USAGE_RESERVE" -> "reserve"
|
||||
"USAGE_SETTLE" -> "settle"
|
||||
@@ -864,6 +892,7 @@ private data class AdminUserDetailResponse(
|
||||
@Serializable
|
||||
private data class AdminLedgerResponse(
|
||||
val entryId: String,
|
||||
val userId: String,
|
||||
val type: String,
|
||||
val amount: Long,
|
||||
val balanceAfter: Long,
|
||||
|
||||
@@ -26,6 +26,7 @@ data class AdminUserPageDto(
|
||||
@Serializable
|
||||
data class AdminUserLedgerEntryDto(
|
||||
val id: String,
|
||||
val userId: String,
|
||||
val type: String,
|
||||
val amountDelta: Long,
|
||||
val balanceAfter: Long,
|
||||
|
||||
+28
@@ -47,6 +47,11 @@ interface AdminUsersRepository {
|
||||
limit: Int,
|
||||
cursor: AdminUserLedgerCursor?,
|
||||
): List<AdminUserLedgerEntryDto>
|
||||
|
||||
suspend fun listLatestLedger(
|
||||
limit: Int,
|
||||
cursor: AdminUserLedgerCursor?,
|
||||
): List<AdminUserLedgerEntryDto>
|
||||
}
|
||||
|
||||
class ExposedAdminUsersRepository(
|
||||
@@ -173,6 +178,28 @@ class ExposedAdminUsersRepository(
|
||||
.limit(limit)
|
||||
.map { it.toUserLedgerRow().toDto() }
|
||||
}
|
||||
|
||||
override suspend fun listLatestLedger(
|
||||
limit: Int,
|
||||
cursor: AdminUserLedgerCursor?,
|
||||
): List<AdminUserLedgerEntryDto> = databaseFactory.query {
|
||||
val query = AdminUsersCreditLedgerTable.selectAll()
|
||||
if (cursor != null) {
|
||||
query.where {
|
||||
(AdminUsersCreditLedgerTable.createdAt less cursor.createdAt) or
|
||||
(
|
||||
(AdminUsersCreditLedgerTable.createdAt eq cursor.createdAt) and
|
||||
(AdminUsersCreditLedgerTable.id less cursor.ledgerEntryId.toString())
|
||||
)
|
||||
}
|
||||
}
|
||||
query.orderBy(
|
||||
AdminUsersCreditLedgerTable.createdAt to SortOrder.DESC,
|
||||
AdminUsersCreditLedgerTable.id to SortOrder.DESC,
|
||||
)
|
||||
.limit(limit)
|
||||
.map { it.toUserLedgerRow().toDto() }
|
||||
}
|
||||
}
|
||||
|
||||
private data class UserSupportRows(
|
||||
@@ -324,6 +351,7 @@ private fun ResultRow.toUserLedgerRow() = UserLedgerRow(
|
||||
|
||||
private fun UserLedgerRow.toDto() = AdminUserLedgerEntryDto(
|
||||
id = id.toString(),
|
||||
userId = userId.toString(),
|
||||
type = type.name,
|
||||
amountDelta = amountDelta,
|
||||
balanceAfter = balanceAfter,
|
||||
|
||||
+19
-2
@@ -1,6 +1,7 @@
|
||||
package com.osglab.account.features.admin.users.services
|
||||
|
||||
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
|
||||
@@ -67,11 +68,27 @@ class AdminUsersService(
|
||||
userId: UUID,
|
||||
limit: Int = 50,
|
||||
cursor: String? = null,
|
||||
): AdminUserLedgerPageDto {
|
||||
return ledgerPage(limit, cursor) { pageSize, decodedCursor ->
|
||||
if (!repository.exists(userId)) throw AdminUserNotFoundException()
|
||||
repository.listLedger(userId, pageSize, decodedCursor)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun latestLedger(
|
||||
limit: Int = 100,
|
||||
cursor: String? = null,
|
||||
): AdminUserLedgerPageDto =
|
||||
ledgerPage(limit, cursor, repository::listLatestLedger)
|
||||
|
||||
private suspend fun ledgerPage(
|
||||
limit: Int,
|
||||
cursor: String?,
|
||||
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)
|
||||
if (!repository.exists(userId)) throw AdminUserNotFoundException()
|
||||
val results = repository.listLedger(userId, limit + 1, decodedCursor)
|
||||
val results = load(limit + 1, decodedCursor)
|
||||
val hasMore = results.size > limit
|
||||
val items = results.take(limit)
|
||||
val nextCursor = if (hasMore) {
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
CREATE INDEX idx_credit_ledger_created
|
||||
ON credit_ledger (created_at, id);
|
||||
@@ -84,10 +84,14 @@ class DeploymentConsistencyTest : FunSpec({
|
||||
val referralMigration = root.read(
|
||||
"src/main/resources/db/migration/V12__align_referral_rewards.sql",
|
||||
)
|
||||
val ledgerTimelineMigration = root.read(
|
||||
"src/main/resources/db/migration/V13__credit_ledger_global_timeline.sql",
|
||||
)
|
||||
profileMigration shouldContain "encrypted_display_name MEDIUMTEXT NOT NULL"
|
||||
profileMigration shouldContain "REFERENCES accounts (id) ON DELETE CASCADE"
|
||||
referralMigration shouldContain "inviter_reward_credits = 1000"
|
||||
referralMigration shouldContain "invitee_reward_credits = 1000"
|
||||
ledgerTimelineMigration shouldContain "ON credit_ledger (created_at, id)"
|
||||
|
||||
listOf(
|
||||
root.read("src/main/resources/application.yaml"),
|
||||
@@ -234,6 +238,7 @@ private val EXPECTED_PUBLIC_PATHS = setOf(
|
||||
"/v1/admin/users",
|
||||
"/v1/admin/users/{userId}",
|
||||
"/v1/admin/users/{userId}/ledger",
|
||||
"/v1/admin/credits/ledger",
|
||||
"/v1/admin/credits/grants",
|
||||
"/v1/admin/operators/summary",
|
||||
"/v1/admin/operators",
|
||||
|
||||
@@ -51,7 +51,7 @@ class SmokeDeploymentTest : FunSpec({
|
||||
|
||||
test("runtime grants cover every migrated table without mutable history privileges") {
|
||||
val grants = root.read("deploy/smoke/runtime-grants.sql")
|
||||
val migrationTables = (1..12)
|
||||
val migrationTables = (1..13)
|
||||
.flatMap { version ->
|
||||
val migration = Files.list(root.resolve("src/main/resources/db/migration")).use { paths ->
|
||||
paths.filter { it.fileName.toString().startsWith("V${version}__") }
|
||||
|
||||
@@ -12,6 +12,8 @@ import com.osglab.account.features.admin.services.AdminOperatorErrorCode
|
||||
import com.osglab.account.features.admin.services.AdminOperatorException
|
||||
import com.osglab.account.features.admin.services.AdminSessionService
|
||||
import com.osglab.account.features.admin.stats.services.AdminStatsService
|
||||
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.services.AdminUsersService
|
||||
import com.osglab.account.features.credits.domain.CreditConflict
|
||||
import com.osglab.account.features.credits.domain.CreditNotFound
|
||||
@@ -199,6 +201,40 @@ class AdminRoutesTest {
|
||||
response.bodyAsText() shouldContain """"code":"INSUFFICIENT_PERMISSION""""
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `support can load latest credit ledger across users`() = testApplication {
|
||||
val usersService = mockk<AdminUsersService>()
|
||||
coEvery { usersService.latestLedger(100, null) } returns AdminUserLedgerPageDto(
|
||||
items = listOf(
|
||||
AdminUserLedgerEntryDto(
|
||||
id = "ffffffff-ffff-ffff-ffff-ffffffffffff",
|
||||
userId = "11111111-1111-4111-8111-111111111111",
|
||||
type = "USAGE_SETTLE",
|
||||
amountDelta = -18,
|
||||
balanceAfter = 102,
|
||||
referenceId = null,
|
||||
createdAt = "2026-08-19T09:00:00Z",
|
||||
),
|
||||
),
|
||||
nextCursor = null,
|
||||
)
|
||||
application {
|
||||
installAdminTestRoutes(
|
||||
sessionService = sessionFixture(AdminRole.SUPPORT),
|
||||
usersService = usersService,
|
||||
)
|
||||
}
|
||||
|
||||
val response = client.get("/v1/admin/credits/ledger") {
|
||||
header("X-OSG-mTLS-Verified", "SUCCESS")
|
||||
header(HttpHeaders.Cookie, "osg_admin_session=session-token")
|
||||
}
|
||||
|
||||
assertEquals(HttpStatusCode.OK, response.status)
|
||||
response.bodyAsText() shouldContain """"userId":"11111111-1111-4111-8111-111111111111""""
|
||||
response.bodyAsText() shouldContain """"reasonCode":"USAGE_SETTLE""""
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `operator creation maps normalized username conflict to 409`() = testApplication {
|
||||
val sessionService = sessionFixture(AdminRole.SUPER_ADMIN)
|
||||
@@ -256,6 +292,7 @@ private fun io.ktor.server.application.Application.installAdminTestRoutes(
|
||||
grantService: AdminGrantService = mockk(relaxed = true),
|
||||
operatorService: AdminOperatorService = mockk(relaxed = true),
|
||||
auditService: AdminAuditService = mockk(relaxed = true),
|
||||
usersService: AdminUsersService = mockk(relaxed = true),
|
||||
) {
|
||||
install(ContentNegotiation) {
|
||||
json(Json { explicitNulls = false })
|
||||
@@ -276,7 +313,7 @@ private fun io.ktor.server.application.Application.installAdminTestRoutes(
|
||||
authService = authService,
|
||||
sessionService = sessionService,
|
||||
statsService = mockk<AdminStatsService>(relaxed = true),
|
||||
usersService = mockk<AdminUsersService>(relaxed = true),
|
||||
usersService = usersService,
|
||||
grantService = grantService,
|
||||
operatorService = operatorService,
|
||||
auditService = auditService,
|
||||
|
||||
@@ -109,6 +109,42 @@ class AdminUsersServiceTest : FunSpec({
|
||||
second.nextCursor shouldBe null
|
||||
}
|
||||
|
||||
test("latest ledger combines users in newest-first order with pagination") {
|
||||
val firstUserId = UUID.randomUUID()
|
||||
val secondUserId = UUID.randomUUID()
|
||||
val newest = ledgerEntry(
|
||||
UUID.fromString("ffffffff-ffff-ffff-ffff-ffffffffffff"),
|
||||
Instant.parse("2026-08-15T03:00:00Z"),
|
||||
firstUserId,
|
||||
)
|
||||
val middle = ledgerEntry(
|
||||
UUID.fromString("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"),
|
||||
Instant.parse("2026-08-15T02:00:00Z"),
|
||||
secondUserId,
|
||||
)
|
||||
val oldest = ledgerEntry(
|
||||
UUID.fromString("dddddddd-dddd-dddd-dddd-dddddddddddd"),
|
||||
Instant.parse("2026-08-15T01:00:00Z"),
|
||||
firstUserId,
|
||||
)
|
||||
val service = AdminUsersService(
|
||||
PagingUsersRepository(
|
||||
users = emptyList(),
|
||||
ledger = mapOf(
|
||||
firstUserId to listOf(newest, oldest),
|
||||
secondUserId to listOf(middle),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val first = service.latestLedger(limit = 2)
|
||||
val second = service.latestLedger(limit = 2, cursor = first.nextCursor.shouldNotBeNull())
|
||||
|
||||
first.items shouldBe listOf(newest, middle)
|
||||
second.items shouldBe listOf(oldest)
|
||||
second.nextCursor shouldBe null
|
||||
}
|
||||
|
||||
test("invalid ledger cursor throws a stable illegal argument exception") {
|
||||
val service = AdminUsersService(PagingUsersRepository(emptyList()))
|
||||
|
||||
@@ -241,6 +277,26 @@ private class PagingUsersRepository(
|
||||
.thenByDescending(AdminUserLedgerEntryDto::id),
|
||||
)
|
||||
.take(limit)
|
||||
|
||||
override suspend fun listLatestLedger(
|
||||
limit: Int,
|
||||
cursor: AdminUserLedgerCursor?,
|
||||
): List<AdminUserLedgerEntryDto> =
|
||||
ledger.values.flatten()
|
||||
.filter {
|
||||
val createdAt = Instant.parse(it.createdAt)
|
||||
cursor == null ||
|
||||
createdAt < cursor.createdAt ||
|
||||
(
|
||||
createdAt == cursor.createdAt &&
|
||||
it.id < cursor.ledgerEntryId.toString()
|
||||
)
|
||||
}
|
||||
.sortedWith(
|
||||
compareByDescending<AdminUserLedgerEntryDto> { Instant.parse(it.createdAt) }
|
||||
.thenByDescending(AdminUserLedgerEntryDto::id),
|
||||
)
|
||||
.take(limit)
|
||||
}
|
||||
|
||||
private fun summary(id: UUID, createdAt: Instant) = AdminUserSummaryDto(
|
||||
@@ -259,8 +315,10 @@ private fun summary(id: UUID, createdAt: Instant) = AdminUserSummaryDto(
|
||||
private fun ledgerEntry(
|
||||
id: UUID,
|
||||
createdAt: Instant,
|
||||
userId: UUID = UUID.fromString("11111111-1111-4111-8111-111111111111"),
|
||||
) = AdminUserLedgerEntryDto(
|
||||
id = id.toString(),
|
||||
userId = userId.toString(),
|
||||
type = "MANUAL_GRANT",
|
||||
amountDelta = 10,
|
||||
balanceAfter = 10,
|
||||
|
||||
Reference in New Issue
Block a user