Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions app/controllers/HaplogroupTreeMergeController.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package controllers

import actions.ApiSecurityAction
import jakarta.inject.{Inject, Singleton}
import models.api.haplogroups.*
import play.api.Logger
import play.api.libs.json.Json
import play.api.mvc.{Action, BaseController, ControllerComponents}
import services.HaplogroupTreeMergeService

import scala.concurrent.ExecutionContext

/**
* API controller for haplogroup tree merge operations.
* Secured with X-API-Key authentication.
*
* Endpoints:
* - POST /api/v1/manage/haplogroups/merge - Full tree merge
* - POST /api/v1/manage/haplogroups/merge/subtree - Subtree merge under anchor
* - POST /api/v1/manage/haplogroups/merge/preview - Preview merge without changes
*/
@Singleton
class HaplogroupTreeMergeController @Inject()(
val controllerComponents: ControllerComponents,
secureApi: ApiSecurityAction,
mergeService: HaplogroupTreeMergeService
)(implicit ec: ExecutionContext) extends BaseController {

private val logger = Logger(this.getClass)

/**
* Merge a full haplogroup tree, replacing the existing tree for the given type.
*
* Request body: TreeMergeRequest
* - haplogroupType: "Y" or "MT"
* - sourceTree: Nested PhyloNodeInput tree structure
* - sourceName: Attribution source (e.g., "ytree.net", "ISOGG")
* - priorityConfig: Optional source priority ordering
* - conflictStrategy: Optional conflict resolution strategy
* - dryRun: If true, simulates merge without applying changes
*/
def mergeFullTree(): Action[TreeMergeRequest] =
secureApi.jsonAction[TreeMergeRequest].async { request =>
logger.info(s"API: Full tree merge for ${request.body.haplogroupType} from ${request.body.sourceName}" +
(if (request.body.dryRun) " (dry run)" else ""))

mergeService.mergeFullTree(request.body).map { response =>
if (response.success) {
Ok(Json.toJson(response))
} else {
BadRequest(Json.toJson(response))
}
}.recover { case e: Exception =>
logger.error(s"Tree merge failed: ${e.getMessage}", e)
InternalServerError(Json.obj(
"success" -> false,
"message" -> "Merge operation failed",
"errors" -> List(e.getMessage)
))
}
}

/**
* Merge a subtree under a specific anchor haplogroup.
*
* Request body: SubtreeMergeRequest
* - haplogroupType: "Y" or "MT"
* - anchorHaplogroupName: Name of the haplogroup to merge under
* - sourceTree: Nested PhyloNodeInput tree structure
* - sourceName: Attribution source
* - priorityConfig: Optional source priority ordering
* - conflictStrategy: Optional conflict resolution strategy
* - dryRun: If true, simulates merge without applying changes
*/
def mergeSubtree(): Action[SubtreeMergeRequest] =
secureApi.jsonAction[SubtreeMergeRequest].async { request =>
logger.info(s"API: Subtree merge under ${request.body.anchorHaplogroupName} " +
s"for ${request.body.haplogroupType} from ${request.body.sourceName}" +
(if (request.body.dryRun) " (dry run)" else ""))

mergeService.mergeSubtree(request.body).map { response =>
if (response.success) {
Ok(Json.toJson(response))
} else {
BadRequest(Json.toJson(response))
}
}.recover {
case e: IllegalArgumentException =>
logger.warn(s"Subtree merge validation failed: ${e.getMessage}")
BadRequest(Json.obj(
"success" -> false,
"message" -> e.getMessage,
"errors" -> List(e.getMessage)
))
case e: Exception =>
logger.error(s"Subtree merge failed: ${e.getMessage}", e)
InternalServerError(Json.obj(
"success" -> false,
"message" -> "Merge operation failed",
"errors" -> List(e.getMessage)
))
}
}

/**
* Preview a merge operation without applying changes.
*
* Request body: MergePreviewRequest
* - haplogroupType: "Y" or "MT"
* - anchorHaplogroupName: Optional anchor for subtree preview
* - sourceTree: Nested PhyloNodeInput tree structure
* - sourceName: Attribution source
* - priorityConfig: Optional source priority ordering
*/
def previewMerge(): Action[MergePreviewRequest] =
secureApi.jsonAction[MergePreviewRequest].async { request =>
logger.info(s"API: Preview merge for ${request.body.haplogroupType} from ${request.body.sourceName}" +
request.body.anchorHaplogroupName.map(a => s" under $a").getOrElse(""))

mergeService.previewMerge(request.body).map { response =>
Ok(Json.toJson(response))
}.recover { case e: Exception =>
logger.error(s"Merge preview failed: ${e.getMessage}", e)
InternalServerError(Json.obj(
"error" -> "Preview operation failed",
"details" -> e.getMessage
))
}
}
}
6 changes: 4 additions & 2 deletions app/controllers/TreeController.scala
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import config.FeatureFlags
import models.HaplogroupType
import models.HaplogroupType.{MT, Y}
import models.api.{SubcladeDTO, TreeNodeDTO}
import models.domain.haplogroups.HaplogroupProvenance
import models.view.TreeViewModel
import org.webjars.play.WebJarsUtil
import play.api.cache.{AsyncCacheApi, Cached}
Expand Down Expand Up @@ -272,8 +273,9 @@ class TreeController @Inject()(val controllerComponents: MessagesControllerCompo
}

def getSnpDetailSidebar(haplogroupName: String, haplogroupType: HaplogroupType): Action[AnyContent] = Action.async { implicit request =>
treeService.findVariantsForHaplogroup(haplogroupName, haplogroupType).map { snps =>
Ok(views.html.fragments.snpDetailSidebar(haplogroupName, snps))
treeService.findHaplogroupWithVariants(haplogroupName, haplogroupType).map { case (haplogroup, snps) =>
val provenance = haplogroup.flatMap(_.provenance)
Ok(views.html.fragments.snpDetailSidebar(haplogroupName, snps, provenance))
}
}

Expand Down
10 changes: 10 additions & 0 deletions app/models/HaplogroupType.scala
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package models

import play.api.libs.json.{Format, Reads, Writes}
import play.api.mvc.QueryStringBindable

/**
Expand Down Expand Up @@ -33,6 +34,15 @@ object HaplogroupType {
case _ => None
}

// JSON serialization
implicit val reads: Reads[HaplogroupType] = Reads.StringReads.map { str =>
fromString(str).getOrElse(throw new IllegalArgumentException(s"Invalid HaplogroupType: $str"))
}

implicit val writes: Writes[HaplogroupType] = Writes.StringWrites.contramap(_.toString)

implicit val format: Format[HaplogroupType] = Format(reads, writes)

implicit val queryStringBindable: QueryStringBindable[HaplogroupType] =
new QueryStringBindable[HaplogroupType] {
def bind(key: String, params: Map[String, Seq[String]]): Option[Either[String, HaplogroupType]] = {
Expand Down
Loading
Loading