From d98dd79191154ebac66ac7011552cb9ef1912541 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 07:10:26 +0000 Subject: [PATCH 1/2] Initial plan From d2b17b258ea746f513a6d6cae40580eeabf0b146 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 07:17:57 +0000 Subject: [PATCH 2/2] feat: add reference_player_duplicate table and admin API for player deduplication Agent-Logs-Url: https://github.com/benckx/elephantchess/sessions/7ab5d051-4357-4383-a36a-62fb93e12563 Co-authored-by: benckx <8626080+benckx@users.noreply.github.com> --- .../liquibase-changelog-generation.xml | 20 +++++++ .../main/resources/liquibase-changelog.xml | 20 +++++++ .../db/services/ReferencePlayerDaoService.kt | 42 +++++++++++++++ .../dto/admin/PlayerDuplicateRequest.kt | 10 ++++ .../dto/admin/PlayerDuplicatesResponse.kt | 10 ++++ .../servicelayer/services/DatabaseService.kt | 10 +++- .../services/admin/AdminDatabaseService.kt | 54 +++++++++++++++++++ .../webapp/routing/api/AdminRouting.kt | 23 ++++++++ 8 files changed, 187 insertions(+), 2 deletions(-) create mode 100644 webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/admin/PlayerDuplicateRequest.kt create mode 100644 webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/admin/PlayerDuplicatesResponse.kt diff --git a/webapp-dao-migration/src/main/resources/liquibase-changelog-generation.xml b/webapp-dao-migration/src/main/resources/liquibase-changelog-generation.xml index a05271e6e..0e47cc9a1 100644 --- a/webapp-dao-migration/src/main/resources/liquibase-changelog-generation.xml +++ b/webapp-dao-migration/src/main/resources/liquibase-changelog-generation.xml @@ -2208,4 +2208,24 @@ + + + + + + + + + + + + diff --git a/webapp-dao-migration/src/main/resources/liquibase-changelog.xml b/webapp-dao-migration/src/main/resources/liquibase-changelog.xml index 0a203b71c..82438e1fd 100644 --- a/webapp-dao-migration/src/main/resources/liquibase-changelog.xml +++ b/webapp-dao-migration/src/main/resources/liquibase-changelog.xml @@ -2361,4 +2361,24 @@ + + + + + + + + + + + + diff --git a/webapp-dao/src/main/kotlin/io/elephantchess/db/services/ReferencePlayerDaoService.kt b/webapp-dao/src/main/kotlin/io/elephantchess/db/services/ReferencePlayerDaoService.kt index 7cf07102f..23f8a7a9a 100644 --- a/webapp-dao/src/main/kotlin/io/elephantchess/db/services/ReferencePlayerDaoService.kt +++ b/webapp-dao/src/main/kotlin/io/elephantchess/db/services/ReferencePlayerDaoService.kt @@ -3,6 +3,7 @@ package io.elephantchess.db.services import io.elephantchess.db.dao.codegen.Tables.* import io.elephantchess.db.dao.codegen.tables.daos.ReferencePlayerDao import io.elephantchess.db.dao.codegen.tables.pojos.ReferencePlayer +import io.elephantchess.db.dao.codegen.tables.pojos.ReferencePlayerDuplicate import io.elephantchess.db.dao.codegen.tables.pojos.ReferencePlayerProfileEdit import io.elephantchess.db.dao.codegen.tables.pojos.ReferencePlayerProfileEditSource import io.elephantchess.db.dao.codegen.tables.records.ReferenceGameRecord @@ -163,6 +164,47 @@ class ReferencePlayerDaoService(private val dslContext: DSLContext) { .awaitMappedRecords() } + suspend fun savePlayerDuplicate(playerId: String, isNewDuplicateOf: String) { + dslContext + .insertInto(REFERENCE_PLAYER_DUPLICATE) + .set(REFERENCE_PLAYER_DUPLICATE.PLAYER_ID, playerId) + .set(REFERENCE_PLAYER_DUPLICATE.IS_DUPLICATE_OF, isNewDuplicateOf) + .onConflict(REFERENCE_PLAYER_DUPLICATE.PLAYER_ID) + .doUpdate() + .set(REFERENCE_PLAYER_DUPLICATE.IS_DUPLICATE_OF, isNewDuplicateOf) + .awaitExecute() + } + + suspend fun findConfirmedDuplicatesOf(canonicalPlayerId: String): List { + return dslContext + .select(REFERENCE_PLAYER_DUPLICATE) + .from(REFERENCE_PLAYER_DUPLICATE) + .where(REFERENCE_PLAYER_DUPLICATE.IS_DUPLICATE_OF.eq(canonicalPlayerId)) + .awaitMappedRecords() + } + + suspend fun findCanonicalPlayerFor(duplicatePlayerId: String): String? { + return dslContext + .select(REFERENCE_PLAYER_DUPLICATE.IS_DUPLICATE_OF) + .from(REFERENCE_PLAYER_DUPLICATE) + .where(REFERENCE_PLAYER_DUPLICATE.PLAYER_ID.eq(duplicatePlayerId)) + .awaitSingleValue() + } + + suspend fun deletePlayerDuplicate(playerId: String) { + dslContext + .deleteFrom(REFERENCE_PLAYER_DUPLICATE) + .where(REFERENCE_PLAYER_DUPLICATE.PLAYER_ID.eq(playerId)) + .awaitExecute() + } + + suspend fun listAllPlayerDuplicates(): List { + return dslContext + .select(REFERENCE_PLAYER_DUPLICATE) + .from(REFERENCE_PLAYER_DUPLICATE) + .awaitMappedRecords() + } + suspend fun listByMostNumberOfGames(limit: Int): List { val gamesAsRed = DSL.`when`(REFERENCE_PLAYER.ID.eq(REFERENCE_GAME.RED_PLAYER), 1).otherwise(0) val gamesAsBlack = DSL.`when`(REFERENCE_PLAYER.ID.eq(REFERENCE_GAME.BLACK_PLAYER), 1).otherwise(0) diff --git a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/admin/PlayerDuplicateRequest.kt b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/admin/PlayerDuplicateRequest.kt new file mode 100644 index 000000000..a65513814 --- /dev/null +++ b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/admin/PlayerDuplicateRequest.kt @@ -0,0 +1,10 @@ +package io.elephantchess.servicelayer.dto.admin + +data class RegisterPlayerDuplicateRequest( + val playerId: String, + val isNewDuplicateOf: String +) + +data class DeletePlayerDuplicateRequest( + val playerId: String +) diff --git a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/admin/PlayerDuplicatesResponse.kt b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/admin/PlayerDuplicatesResponse.kt new file mode 100644 index 000000000..38b967427 --- /dev/null +++ b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/admin/PlayerDuplicatesResponse.kt @@ -0,0 +1,10 @@ +package io.elephantchess.servicelayer.dto.admin + +data class PlayerDuplicatesResponse(val entries: List) { + data class Entry( + val playerId: String, + val playerCanonicalName: String, + val isDuplicateOf: String, + val canonicalPlayerCanonicalName: String + ) +} diff --git a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/DatabaseService.kt b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/DatabaseService.kt index 623381710..389aacd1b 100644 --- a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/DatabaseService.kt +++ b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/DatabaseService.kt @@ -440,8 +440,8 @@ class DatabaseService( // fetch stats for player alone val playerStats = referencePlayerDaoService.fetchGameStats(playerId) - // find possible duplicates - val duplicates = findPossibleDuplicates(playerId) + // find confirmed duplicates first; fall back to name-based heuristics + val duplicates = findConfirmedDuplicates(playerId).ifEmpty { findPossibleDuplicates(playerId) } // fetch stats including duplicates if any exist val statsWithDuplicates = @@ -474,6 +474,12 @@ class DatabaseService( ) } + private suspend fun findConfirmedDuplicates(playerId: String): List { + return referencePlayerDaoService + .findConfirmedDuplicatesOf(playerId) + .mapNotNull { duplicate -> referencePlayerDaoService.findPlayer(duplicate.playerId) } + } + private suspend fun findPossibleDuplicates(playerId: String): List { val player = referencePlayerDaoService.findPlayer(playerId) ?: throw NotFoundException("player $playerId not found") diff --git a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/admin/AdminDatabaseService.kt b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/admin/AdminDatabaseService.kt index e4345f8d0..6f0fc64ff 100644 --- a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/admin/AdminDatabaseService.kt +++ b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/admin/AdminDatabaseService.kt @@ -1,8 +1,11 @@ package io.elephantchess.servicelayer.services.admin import io.elephantchess.db.services.ReferencePlayerDaoService +import io.elephantchess.servicelayer.dto.admin.PlayerDuplicatesResponse import io.elephantchess.servicelayer.dto.database.DatabasePlayerProfileVersionHistoryEntry import io.elephantchess.servicelayer.dto.database.DatabasePlayerVersionHistory +import io.elephantchess.servicelayer.exceptions.BadRequestException +import io.elephantchess.servicelayer.exceptions.NotFoundException import io.elephantchess.servicelayer.services.UserCache class AdminDatabaseService( @@ -30,4 +33,55 @@ class AdminDatabaseService( } } + suspend fun listPlayerDuplicates(): PlayerDuplicatesResponse { + return referencePlayerDaoService + .listAllPlayerDuplicates() + .mapNotNull { duplicate -> + val player = referencePlayerDaoService.findPlayer(duplicate.playerId) ?: return@mapNotNull null + val canonicalPlayer = + referencePlayerDaoService.findPlayer(duplicate.isDuplicateOf) ?: return@mapNotNull null + PlayerDuplicatesResponse.Entry( + playerId = duplicate.playerId, + playerCanonicalName = player.canonicalName, + isDuplicateOf = duplicate.isDuplicateOf, + canonicalPlayerCanonicalName = canonicalPlayer.canonicalName + ) + } + .let { entries -> PlayerDuplicatesResponse(entries) } + } + + suspend fun registerPlayerDuplicate(playerId: String, isNewDuplicateOf: String) { + if (playerId == isNewDuplicateOf) { + throw BadRequestException("a player cannot be a duplicate of itself") + } + referencePlayerDaoService.findPlayer(playerId) + ?: throw NotFoundException("player $playerId not found") + referencePlayerDaoService.findPlayer(isNewDuplicateOf) + ?: throw NotFoundException("player $isNewDuplicateOf not found") + + // prevent cycles: the canonical player must not itself be registered as a duplicate + val canonicalIsAlreadyDuplicate = referencePlayerDaoService.findCanonicalPlayerFor(isNewDuplicateOf) + if (canonicalIsAlreadyDuplicate != null) { + throw BadRequestException( + "player $isNewDuplicateOf is already registered as a duplicate of $canonicalIsAlreadyDuplicate; " + + "resolve that relationship first" + ) + } + + // prevent reverse cycle: the new duplicate must not already be the canonical for others + val playersAlreadyDuplicateOf = referencePlayerDaoService.findConfirmedDuplicatesOf(playerId) + if (playersAlreadyDuplicateOf.isNotEmpty()) { + throw BadRequestException( + "player $playerId is already listed as a canonical player for other duplicates; " + + "remove those relationships first" + ) + } + + referencePlayerDaoService.savePlayerDuplicate(playerId, isNewDuplicateOf) + } + + suspend fun deletePlayerDuplicate(playerId: String) { + referencePlayerDaoService.deletePlayerDuplicate(playerId) + } + } diff --git a/webapp/src/main/kotlin/io/elephantchess/webapp/routing/api/AdminRouting.kt b/webapp/src/main/kotlin/io/elephantchess/webapp/routing/api/AdminRouting.kt index a44db7dbd..302d27873 100644 --- a/webapp/src/main/kotlin/io/elephantchess/webapp/routing/api/AdminRouting.kt +++ b/webapp/src/main/kotlin/io/elephantchess/webapp/routing/api/AdminRouting.kt @@ -1,6 +1,8 @@ package io.elephantchess.webapp.routing.api import io.elephantchess.servicelayer.dto.admin.CreateUpcomingEventRequest +import io.elephantchess.servicelayer.dto.admin.DeletePlayerDuplicateRequest +import io.elephantchess.servicelayer.dto.admin.RegisterPlayerDuplicateRequest import io.elephantchess.servicelayer.dto.admin.ToggleUpcomingEventRequest import io.elephantchess.servicelayer.dto.admin.UpdateUpcomingEventRequest import io.elephantchess.servicelayer.services.admin.* @@ -25,6 +27,7 @@ fun Route.adminConsoleRoutes() { adminExceptionRoutes() adminNewsletterRoutes() adminUpcomingEventsRoutes() + adminPlayerDuplicatesRoutes() } } @@ -255,3 +258,23 @@ private fun Route.adminUpcomingEventsRoutes() { } } } + +private fun Route.adminPlayerDuplicatesRoutes() { + val adminDatabaseService by koin() + + get("/player-duplicates") { + requireAdminRole { adminDatabaseService.listPlayerDuplicates() } + } + post("/player-duplicates") { + requireAdminRole { + val request = call.receive() + adminDatabaseService.registerPlayerDuplicate(request.playerId, request.isNewDuplicateOf) + } + } + delete("/player-duplicates") { + requireAdminRole { + val request = call.receive() + adminDatabaseService.deletePlayerDuplicate(request.playerId) + } + } +}