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)
+ }
+ }
+}