diff --git a/app/controllers/HaplogroupTreeMergeController.scala b/app/controllers/HaplogroupTreeMergeController.scala new file mode 100644 index 0000000..41328ab --- /dev/null +++ b/app/controllers/HaplogroupTreeMergeController.scala @@ -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 + )) + } + } +} diff --git a/app/controllers/TreeController.scala b/app/controllers/TreeController.scala index e175c83..1de184b 100644 --- a/app/controllers/TreeController.scala +++ b/app/controllers/TreeController.scala @@ -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} @@ -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)) } } diff --git a/app/models/HaplogroupType.scala b/app/models/HaplogroupType.scala index 4949cb6..e35d415 100644 --- a/app/models/HaplogroupType.scala +++ b/app/models/HaplogroupType.scala @@ -1,5 +1,6 @@ package models +import play.api.libs.json.{Format, Reads, Writes} import play.api.mvc.QueryStringBindable /** @@ -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]] = { diff --git a/app/models/api/haplogroups/TreeMergeModels.scala b/app/models/api/haplogroups/TreeMergeModels.scala new file mode 100644 index 0000000..1c09bd3 --- /dev/null +++ b/app/models/api/haplogroups/TreeMergeModels.scala @@ -0,0 +1,253 @@ +package models.api.haplogroups + +import models.HaplogroupType +import play.api.libs.json.{Format, Json, OFormat, Reads, Writes} + +/** + * API DTOs for Haplogroup Tree Merge operations. + * + * Supports merging external haplogroup trees from sources like ISOGG, ytree.net, + * and other researchers into the DecodingUs baseline tree. + */ + +// ============================================================================ +// Input Tree Structure +// ============================================================================ + +/** + * A variant with its primary name and optional aliases. + * Aliases represent alternative names for the same SNP from different labs/sources. + * Example: M207 (primary) with aliases Page37, UTY2 + */ +case class VariantInput( + name: String, + aliases: List[String] = List.empty +) + +object VariantInput { + implicit val format: OFormat[VariantInput] = Json.format[VariantInput] +} + +/** + * A node in the input phylogenetic tree for merging. + * Matching is done by variants, not names, to handle different naming conventions. + */ +case class PhyloNodeInput( + name: String, + variants: List[VariantInput] = List.empty, + formedYbp: Option[Int] = None, + formedYbpLower: Option[Int] = None, + formedYbpUpper: Option[Int] = None, + tmrcaYbp: Option[Int] = None, + tmrcaYbpLower: Option[Int] = None, + tmrcaYbpUpper: Option[Int] = None, + children: List[PhyloNodeInput] = List.empty +) + +object PhyloNodeInput { + implicit val format: OFormat[PhyloNodeInput] = Json.format[PhyloNodeInput] +} + +// ============================================================================ +// Merge Configuration +// ============================================================================ + +/** + * Configuration for source priority during merge. + * Lower index = higher priority. + */ +case class SourcePriorityConfig( + sourcePriorities: List[String], + defaultPriority: Int = 100 +) + +object SourcePriorityConfig { + implicit val format: OFormat[SourcePriorityConfig] = Json.format[SourcePriorityConfig] +} + +/** + * Strategy for handling conflicts during merge. + */ +sealed trait ConflictStrategy + +object ConflictStrategy { + case object HigherPriorityWins extends ConflictStrategy + case object KeepExisting extends ConflictStrategy + case object AlwaysUpdate extends ConflictStrategy + + implicit val reads: Reads[ConflictStrategy] = Reads.StringReads.map { + case "higher_priority_wins" => HigherPriorityWins + case "keep_existing" => KeepExisting + case "always_update" => AlwaysUpdate + case other => throw new IllegalArgumentException(s"Unknown conflict strategy: $other") + } + + implicit val writes: Writes[ConflictStrategy] = Writes.StringWrites.contramap { + case HigherPriorityWins => "higher_priority_wins" + case KeepExisting => "keep_existing" + case AlwaysUpdate => "always_update" + } + + implicit val format: Format[ConflictStrategy] = Format(reads, writes) +} + +// ============================================================================ +// Request DTOs +// ============================================================================ + +/** + * Request for full tree merge (replace entire Y-DNA or mtDNA tree). + */ +case class TreeMergeRequest( + haplogroupType: HaplogroupType, + sourceTree: PhyloNodeInput, + sourceName: String, + priorityConfig: Option[SourcePriorityConfig] = None, + conflictStrategy: Option[ConflictStrategy] = None, + dryRun: Boolean = false +) + +object TreeMergeRequest { + implicit val format: OFormat[TreeMergeRequest] = Json.format[TreeMergeRequest] +} + +/** + * Request for subtree merge (merge under a specific anchor node). + */ +case class SubtreeMergeRequest( + haplogroupType: HaplogroupType, + anchorHaplogroupName: String, + sourceTree: PhyloNodeInput, + sourceName: String, + priorityConfig: Option[SourcePriorityConfig] = None, + conflictStrategy: Option[ConflictStrategy] = None, + dryRun: Boolean = false +) + +object SubtreeMergeRequest { + implicit val format: OFormat[SubtreeMergeRequest] = Json.format[SubtreeMergeRequest] +} + +/** + * Request for merge preview. + */ +case class MergePreviewRequest( + haplogroupType: HaplogroupType, + anchorHaplogroupName: Option[String] = None, + sourceTree: PhyloNodeInput, + sourceName: String, + priorityConfig: Option[SourcePriorityConfig] = None +) + +object MergePreviewRequest { + implicit val format: OFormat[MergePreviewRequest] = Json.format[MergePreviewRequest] +} + +// ============================================================================ +// Response DTOs +// ============================================================================ + +/** + * Statistics from a merge operation. + */ +case class MergeStatistics( + nodesProcessed: Int, + nodesCreated: Int, + nodesUpdated: Int, + nodesUnchanged: Int, + variantsAdded: Int, + variantsUpdated: Int, + relationshipsCreated: Int, + relationshipsUpdated: Int, + splitOperations: Int = 0 +) + +object MergeStatistics { + implicit val format: OFormat[MergeStatistics] = Json.format[MergeStatistics] + + val empty: MergeStatistics = MergeStatistics(0, 0, 0, 0, 0, 0, 0, 0, 0) + + def combine(a: MergeStatistics, b: MergeStatistics): MergeStatistics = MergeStatistics( + nodesProcessed = a.nodesProcessed + b.nodesProcessed, + nodesCreated = a.nodesCreated + b.nodesCreated, + nodesUpdated = a.nodesUpdated + b.nodesUpdated, + nodesUnchanged = a.nodesUnchanged + b.nodesUnchanged, + variantsAdded = a.variantsAdded + b.variantsAdded, + variantsUpdated = a.variantsUpdated + b.variantsUpdated, + relationshipsCreated = a.relationshipsCreated + b.relationshipsCreated, + relationshipsUpdated = a.relationshipsUpdated + b.relationshipsUpdated, + splitOperations = a.splitOperations + b.splitOperations + ) +} + +/** + * Details of a conflict encountered during merge. + */ +case class MergeConflict( + haplogroupName: String, + field: String, + existingValue: String, + newValue: String, + resolution: String, + existingSource: String, + newSource: String +) + +object MergeConflict { + implicit val format: OFormat[MergeConflict] = Json.format[MergeConflict] +} + +/** + * Details of a split operation performed during merge. + */ +case class SplitOperation( + parentName: String, + newIntermediateName: String, + variantsRedistributed: List[String], + childrenReassigned: List[String], + source: String +) + +object SplitOperation { + implicit val format: OFormat[SplitOperation] = Json.format[SplitOperation] +} + +/** + * Result of a merge operation. + */ +case class TreeMergeResponse( + success: Boolean, + message: String, + statistics: MergeStatistics, + conflicts: List[MergeConflict] = List.empty, + splits: List[SplitOperation] = List.empty, + errors: List[String] = List.empty +) + +object TreeMergeResponse { + implicit val format: OFormat[TreeMergeResponse] = Json.format[TreeMergeResponse] + + def failure(message: String, errors: List[String] = List.empty): TreeMergeResponse = + TreeMergeResponse( + success = false, + message = message, + statistics = MergeStatistics.empty, + errors = errors + ) +} + +/** + * Preview of merge results (without applying changes). + */ +case class MergePreviewResponse( + statistics: MergeStatistics, + conflicts: List[MergeConflict], + splits: List[SplitOperation], + newNodes: List[String], + updatedNodes: List[String], + unchangedNodes: List[String] +) + +object MergePreviewResponse { + implicit val format: OFormat[MergePreviewResponse] = Json.format[MergePreviewResponse] +} diff --git a/app/models/dal/MyPostgresProfile.scala b/app/models/dal/MyPostgresProfile.scala index fea3530..36b675c 100644 --- a/app/models/dal/MyPostgresProfile.scala +++ b/app/models/dal/MyPostgresProfile.scala @@ -276,7 +276,8 @@ trait MyPostgresProfile extends ExPostgresProfile case None => JsNull }, { jsValue => - if (jsValue == JsNull || (jsValue.isInstanceOf[JsObject] && jsValue.as[JsObject].value.isEmpty)) None + // Handle database NULL (Java null), JSON null, or empty object + if (jsValue == null || jsValue == JsNull || (jsValue.isInstanceOf[JsObject] && jsValue.as[JsObject].value.isEmpty)) None else Some(jsValue.as[IdentityVerification]) } ) @@ -290,7 +291,8 @@ trait MyPostgresProfile extends ExPostgresProfile case None => JsNull }, { jsValue => - if (jsValue == JsNull || (jsValue.isInstanceOf[JsObject] && jsValue.as[JsObject].value.isEmpty)) None + // Handle database NULL (Java null), JSON null, or empty object + if (jsValue == null || jsValue == JsNull || (jsValue.isInstanceOf[JsObject] && jsValue.as[JsObject].value.isEmpty)) None else Some(jsValue.as[ManualOverride]) } ) @@ -304,12 +306,21 @@ trait MyPostgresProfile extends ExPostgresProfile case None => JsNull }, { jsValue => - if (jsValue == JsNull) None + // Handle database NULL (Java null) or JSON null + if (jsValue == null || jsValue == JsNull) None else Some(jsValue.as[Seq[AuditEntry]]) } ) } + // --- Haplogroup Provenance JSONB Type Mapper --- + // Maps HaplogroupProvenance directly to JsValue. For nullable columns, use column[Option[HaplogroupProvenance]] + // and Slick will handle NULL automatically. + import models.domain.haplogroups.HaplogroupProvenance + + implicit val haplogroupProvenanceJsonbTypeMapper: JdbcType[HaplogroupProvenance] with BaseTypedType[HaplogroupProvenance] = + MappedJdbcType.base[HaplogroupProvenance, JsValue](Json.toJson(_), _.as[HaplogroupProvenance]) + // Declare the name of an aggregate function: val ArrayAgg = new SqlAggregateFunction("array_agg") diff --git a/app/models/dal/domain/haplogroups/HaplogroupsTable.scala b/app/models/dal/domain/haplogroups/HaplogroupsTable.scala index 1a71964..ab8e018 100644 --- a/app/models/dal/domain/haplogroups/HaplogroupsTable.scala +++ b/app/models/dal/domain/haplogroups/HaplogroupsTable.scala @@ -2,7 +2,7 @@ package models.dal.domain.haplogroups import models.HaplogroupType import models.dal.MyPostgresProfile.api.* -import models.domain.haplogroups.Haplogroup +import models.domain.haplogroups.{Haplogroup, HaplogroupProvenance} import slick.ast.TypedType import slick.lifted.{MappedProjection, ProvenShape} @@ -71,8 +71,11 @@ class HaplogroupsTable(tag: Tag) extends Table[Haplogroup](tag, Some("tree"), "h def ageEstimateSource = column[Option[String]]("age_estimate_source") + // Multi-source provenance tracking (JSONB) + def provenance = column[Option[HaplogroupProvenance]]("provenance") + def * = ( haplogroupId.?, name, lineage, description, haplogroupType, revisionId, source, confidenceLevel, validFrom, validUntil, - formedYbp, formedYbpLower, formedYbpUpper, tmrcaYbp, tmrcaYbpLower, tmrcaYbpUpper, ageEstimateSource + formedYbp, formedYbpLower, formedYbpUpper, tmrcaYbp, tmrcaYbpLower, tmrcaYbpUpper, ageEstimateSource, provenance ).mapTo[Haplogroup] } diff --git a/app/models/domain/haplogroups/Haplogroup.scala b/app/models/domain/haplogroups/Haplogroup.scala index 7b0ce5a..d177ffe 100644 --- a/app/models/domain/haplogroups/Haplogroup.scala +++ b/app/models/domain/haplogroups/Haplogroup.scala @@ -80,7 +80,8 @@ case class Haplogroup( tmrcaYbp: Option[Int] = None, tmrcaYbpLower: Option[Int] = None, tmrcaYbpUpper: Option[Int] = None, - ageEstimateSource: Option[String] = None + ageEstimateSource: Option[String] = None, + provenance: Option[HaplogroupProvenance] = None ) { /** Get formed date as AgeEstimate if available */ def formedEstimate: Option[AgeEstimate] = formedYbp.map(y => AgeEstimate(y, formedYbpLower, formedYbpUpper)) diff --git a/app/models/domain/haplogroups/HaplogroupProvenance.scala b/app/models/domain/haplogroups/HaplogroupProvenance.scala new file mode 100644 index 0000000..cb98a14 --- /dev/null +++ b/app/models/domain/haplogroups/HaplogroupProvenance.scala @@ -0,0 +1,102 @@ +package models.domain.haplogroups + +import play.api.libs.json.{Json, OFormat, Format, Reads, Writes} + +import java.time.LocalDateTime + +/** + * Tracks the provenance of a haplogroup node and its variants from multiple sources. + * + * Credit assignment follows a tiered model: + * - ISOGG credit is preserved on existing nodes (authoritative backbone) + * - Incoming sources get credit for new splits and terminal branches they contribute + * + * @param primaryCredit Source with primary discovery credit for this node + * @param nodeProvenance All sources that have contributed to this node's existence + * @param variantProvenance Per-variant source attribution (variant name -> set of sources) + * @param lastMergedAt Timestamp of the most recent merge operation affecting this node + * @param lastMergedFrom Source of the most recent merge operation + */ +case class HaplogroupProvenance( + primaryCredit: String, + nodeProvenance: Set[String] = Set.empty, + variantProvenance: Map[String, Set[String]] = Map.empty, + lastMergedAt: Option[LocalDateTime] = None, + lastMergedFrom: Option[String] = None +) { + + /** + * Add a source to nodeProvenance. + */ + def addNodeSource(source: String): HaplogroupProvenance = + copy(nodeProvenance = nodeProvenance + source) + + /** + * Add a source attribution for a specific variant. + */ + def addVariantSource(variantName: String, source: String): HaplogroupProvenance = + copy(variantProvenance = variantProvenance.updatedWith(variantName) { + case Some(sources) => Some(sources + source) + case None => Some(Set(source)) + }) + + /** + * Merge another provenance record into this one, combining all sources. + */ + def merge(other: HaplogroupProvenance): HaplogroupProvenance = { + val mergedVariants = (variantProvenance.keySet ++ other.variantProvenance.keySet).map { key => + key -> (variantProvenance.getOrElse(key, Set.empty) ++ other.variantProvenance.getOrElse(key, Set.empty)) + }.toMap + + HaplogroupProvenance( + primaryCredit = this.primaryCredit, // Preserve existing primary credit + nodeProvenance = nodeProvenance ++ other.nodeProvenance, + variantProvenance = mergedVariants, + lastMergedAt = Seq(lastMergedAt, other.lastMergedAt).flatten.maxOption, + lastMergedFrom = other.lastMergedFrom.orElse(lastMergedFrom) + ) + } + + /** + * Update merge timestamp and source. + */ + def withMergeInfo(source: String, timestamp: LocalDateTime): HaplogroupProvenance = + copy(lastMergedAt = Some(timestamp), lastMergedFrom = Some(source)) +} + +object HaplogroupProvenance { + // Custom JSON format to handle Set[String] and Map[String, Set[String]] + implicit val setStringFormat: Format[Set[String]] = Format( + Reads.seq[String].map(_.toSet), + Writes.seq[String].contramap(_.toSeq) + ) + + implicit val mapStringSetFormat: Format[Map[String, Set[String]]] = Format( + Reads.map[Set[String]], + Writes.map[Set[String]] + ) + + implicit val format: OFormat[HaplogroupProvenance] = Json.format[HaplogroupProvenance] + + val empty: HaplogroupProvenance = HaplogroupProvenance(primaryCredit = "") + + /** + * Create initial provenance for a new node from a source. + */ + def forNewNode(source: String, variants: Seq[String] = Seq.empty): HaplogroupProvenance = { + val variantProv = variants.map(v => v -> Set(source)).toMap + HaplogroupProvenance( + primaryCredit = source, + nodeProvenance = Set(source), + variantProvenance = variantProv, + lastMergedAt = Some(LocalDateTime.now()), + lastMergedFrom = Some(source) + ) + } + + /** + * Determine if ISOGG credit should be preserved (returns true if existing credit is ISOGG). + */ + def shouldPreserveCredit(existingCredit: String): Boolean = + existingCredit.equalsIgnoreCase("ISOGG") +} diff --git a/app/modules/ServicesModule.scala b/app/modules/ServicesModule.scala index 7e83b97..f79ee3d 100644 --- a/app/modules/ServicesModule.scala +++ b/app/modules/ServicesModule.scala @@ -28,6 +28,7 @@ class ServicesModule(environment: Environment, configuration: Configuration) ext bind(classOf[services.PublicationDiscoveryService]).asEagerSingleton() bind(classOf[services.UserPermissionHelper]).asEagerSingleton() + bind(classOf[services.HaplogroupTreeMergeService]).asEagerSingleton() } } diff --git a/app/repositories/HaplogroupCoreRepository.scala b/app/repositories/HaplogroupCoreRepository.scala index d3dd40c..ca3e822 100644 --- a/app/repositories/HaplogroupCoreRepository.scala +++ b/app/repositories/HaplogroupCoreRepository.scala @@ -2,7 +2,7 @@ package repositories import jakarta.inject.Inject import models.HaplogroupType -import models.domain.haplogroups.Haplogroup +import models.domain.haplogroups.{Haplogroup, HaplogroupProvenance} import play.api.Logging import play.api.db.slick.DatabaseConfigProvider import slick.jdbc.GetResult @@ -117,6 +117,26 @@ trait HaplogroupCoreRepository { * @return a sequence of root haplogroups for that type */ def findRoots(haplogroupType: HaplogroupType): Future[Seq[Haplogroup]] + + // === Tree Merge Methods === + + /** + * Update the provenance field for a haplogroup. + * + * @param id the haplogroup ID + * @param provenance the new provenance data + * @return true if updated successfully + */ + def updateProvenance(id: Int, provenance: HaplogroupProvenance): Future[Boolean] + + /** + * Get all haplogroups of a type with their associated variant names. + * Used for building variant-based lookup index for merge operations. + * + * @param haplogroupType the type of haplogroup (Y or MT) + * @return sequence of tuples: (haplogroup, list of variant names) + */ + def getAllWithVariantNames(haplogroupType: HaplogroupType): Future[Seq[(Haplogroup, Seq[String])]] } class HaplogroupCoreRepositoryImpl @Inject()( @@ -413,4 +433,40 @@ class HaplogroupCoreRepositoryImpl @Inject()( runQuery(query) } + + // === Tree Merge Methods Implementation === + + override def updateProvenance(id: Int, provenance: HaplogroupProvenance): Future[Boolean] = { + runQuery( + haplogroups + .filter(_.haplogroupId === id) + .map(_.provenance) + .update(Some(provenance)) + ).map(_ > 0) + } + + override def getAllWithVariantNames(haplogroupType: HaplogroupType): Future[Seq[(Haplogroup, Seq[String])]] = { + import models.dal.DatabaseSchema.domain.haplogroups.haplogroupVariants + import models.dal.DatabaseSchema.domain.genomics.variants + + // Query haplogroups with their associated variant names via join + val query = for { + hg <- activeHaplogroups.filter(_.haplogroupType === haplogroupType) + } yield hg + + runQuery(query.result).flatMap { hgList => + // For each haplogroup, fetch its variant names (using commonName from Variant table) + val futures = hgList.map { hg => + val variantQuery = for { + hv <- haplogroupVariants.filter(_.haplogroupId === hg.id.get) + v <- variants.filter(_.variantId === hv.variantId) + } yield v.commonName + + runQuery(variantQuery.result).map { variantNames => + (hg, variantNames.flatten) // Filter out None values + } + } + Future.sequence(futures) + } + } } diff --git a/app/repositories/HaplogroupVariantRepository.scala b/app/repositories/HaplogroupVariantRepository.scala index 66477a4..68368ee 100644 --- a/app/repositories/HaplogroupVariantRepository.scala +++ b/app/repositories/HaplogroupVariantRepository.scala @@ -174,9 +174,12 @@ class HaplogroupVariantRepositoryImpl @Inject()( } override def addVariantToHaplogroup(haplogroupId: Int, variantId: Int): Future[Int] = { - val insertion = (haplogroupVariants returning haplogroupVariants.map(_.haplogroupVariantId)) += - HaplogroupVariant(None, haplogroupId, variantId) - runQuery(insertion) + val insertAction = sqlu""" + INSERT INTO haplogroup_variant (haplogroup_id, variant_id) + VALUES ($haplogroupId, $variantId) + ON CONFLICT (haplogroup_id, variant_id) DO NOTHING + """ + runQuery(insertAction) } def removeVariantFromHaplogroup(haplogroupId: Int, variantId: Int): Future[Int] = { diff --git a/app/services/HaplogroupTreeMergeService.scala b/app/services/HaplogroupTreeMergeService.scala new file mode 100644 index 0000000..955c9d6 --- /dev/null +++ b/app/services/HaplogroupTreeMergeService.scala @@ -0,0 +1,670 @@ +package services + +import jakarta.inject.{Inject, Singleton} +import models.HaplogroupType +import models.api.haplogroups.* +import models.dal.domain.genomics.VariantAlias +import models.domain.haplogroups.{Haplogroup, HaplogroupProvenance} +import play.api.Logging +import repositories.{HaplogroupCoreRepository, HaplogroupVariantRepository, VariantAliasRepository, VariantRepository} + +import java.time.LocalDateTime +import scala.concurrent.{ExecutionContext, Future} + +/** + * Service for merging external haplogroup trees into the DecodingUs baseline tree. + * + * Key features: + * - Variant-based matching: Nodes are matched by their defining variants, not names, + * to handle different naming conventions across sources (ytree.net, ISOGG, researchers) + * - Credit assignment: ISOGG credit preserved on existing nodes; incoming sources get + * credit for new splits and terminal branches they contribute + * - Multi-source provenance: Full attribution tracking via JSONB column + * - Branch split detection: Identifies when incoming data reveals finer tree structure + */ +@Singleton +class HaplogroupTreeMergeService @Inject()( + haplogroupRepository: HaplogroupCoreRepository, + haplogroupVariantRepository: HaplogroupVariantRepository, + variantRepository: VariantRepository, + variantAliasRepository: VariantAliasRepository +)(implicit ec: ExecutionContext) extends Logging { + + // ============================================================================ + // Helper methods for VariantInput + // ============================================================================ + + /** Extract all variant names (primary + aliases) from a VariantInput */ + private def allVariantNames(variant: VariantInput): List[String] = + variant.name :: variant.aliases + + /** Extract all variant names from a list of VariantInput */ + private def allVariantNames(variants: List[VariantInput]): List[String] = + variants.flatMap(allVariantNames) + + /** Extract just the primary variant names from a list of VariantInput */ + private def primaryVariantNames(variants: List[VariantInput]): List[String] = + variants.map(_.name) + + // ============================================================================ + // Public API + // ============================================================================ + + /** + * Merge a full tree, replacing the existing tree for the given haplogroup type. + */ + def mergeFullTree(request: TreeMergeRequest): Future[TreeMergeResponse] = { + if (request.dryRun) { + previewMerge(MergePreviewRequest( + haplogroupType = request.haplogroupType, + anchorHaplogroupName = None, + sourceTree = request.sourceTree, + sourceName = request.sourceName, + priorityConfig = request.priorityConfig + )).map(preview => TreeMergeResponse( + success = true, + message = "Dry run completed successfully", + statistics = preview.statistics, + conflicts = preview.conflicts, + splits = preview.splits + )) + } else { + performMerge( + haplogroupType = request.haplogroupType, + anchorId = None, + sourceTree = request.sourceTree, + sourceName = request.sourceName, + priorityConfig = request.priorityConfig.getOrElse(SourcePriorityConfig(List.empty)), + conflictStrategy = request.conflictStrategy.getOrElse(ConflictStrategy.HigherPriorityWins) + ) + } + } + + /** + * Merge a subtree under a specific anchor haplogroup. + */ + def mergeSubtree(request: SubtreeMergeRequest): Future[TreeMergeResponse] = { + if (request.dryRun) { + previewMerge(MergePreviewRequest( + haplogroupType = request.haplogroupType, + anchorHaplogroupName = Some(request.anchorHaplogroupName), + sourceTree = request.sourceTree, + sourceName = request.sourceName, + priorityConfig = request.priorityConfig + )).map(preview => TreeMergeResponse( + success = true, + message = "Dry run completed successfully", + statistics = preview.statistics, + conflicts = preview.conflicts, + splits = preview.splits + )) + } else { + for { + // Find the anchor haplogroup + anchorOpt <- haplogroupRepository.getHaplogroupByName(request.anchorHaplogroupName, request.haplogroupType) + anchor = anchorOpt.getOrElse( + throw new IllegalArgumentException(s"Anchor haplogroup '${request.anchorHaplogroupName}' not found") + ) + + result <- performMerge( + haplogroupType = request.haplogroupType, + anchorId = anchor.id, + sourceTree = request.sourceTree, + sourceName = request.sourceName, + priorityConfig = request.priorityConfig.getOrElse(SourcePriorityConfig(List.empty)), + conflictStrategy = request.conflictStrategy.getOrElse(ConflictStrategy.HigherPriorityWins) + ) + } yield result + } + } + + /** + * Preview merge without applying changes. + */ + def previewMerge(request: MergePreviewRequest): Future[MergePreviewResponse] = { + for { + // Build variant-based index of existing haplogroups + existingIndex <- buildVariantIndex(request.haplogroupType) + + // Simulate the merge to collect statistics + preview <- simulateMerge( + sourceTree = request.sourceTree, + sourceName = request.sourceName, + existingIndex = existingIndex, + priorityConfig = request.priorityConfig.getOrElse(SourcePriorityConfig(List.empty)) + ) + } yield preview + } + + // ============================================================================ + // Private Implementation + // ============================================================================ + + /** + * Build an index of existing haplogroups by their variant names. + * This enables variant-based matching across different naming conventions. + */ + private def buildVariantIndex(haplogroupType: HaplogroupType): Future[VariantIndex] = { + haplogroupRepository.getAllWithVariantNames(haplogroupType).map { haplogroupsWithVariants => + val variantToHaplogroup = haplogroupsWithVariants.flatMap { case (hg, variants) => + variants.map(v => v.toUpperCase -> hg) + }.groupMap(_._1)(_._2) + + val haplogroupByName = haplogroupsWithVariants.map { case (hg, _) => + hg.name.toUpperCase -> hg + }.toMap + + VariantIndex(variantToHaplogroup, haplogroupByName) + } + } + + /** + * Perform the actual merge operation. + */ + private def performMerge( + haplogroupType: HaplogroupType, + anchorId: Option[Int], + sourceTree: PhyloNodeInput, + sourceName: String, + priorityConfig: SourcePriorityConfig, + conflictStrategy: ConflictStrategy + ): Future[TreeMergeResponse] = { + val now = LocalDateTime.now() + val context = MergeContext( + haplogroupType = haplogroupType, + sourceName = sourceName, + priorityConfig = priorityConfig, + conflictStrategy = conflictStrategy, + timestamp = now + ) + + for { + // Build variant-based index + existingIndex <- buildVariantIndex(haplogroupType) + + // Perform recursive merge + result <- mergeNode( + node = sourceTree, + parentId = anchorId, + context = context, + index = existingIndex, + accumulator = MergeAccumulator.empty + ) + } yield TreeMergeResponse( + success = result.errors.isEmpty, + message = if (result.errors.isEmpty) "Merge completed successfully" else "Merge completed with errors", + statistics = result.statistics, + conflicts = result.conflicts, + splits = result.splits, + errors = result.errors + ) + } + + /** + * Recursively merge a node and its children. + */ + private def mergeNode( + node: PhyloNodeInput, + parentId: Option[Int], + context: MergeContext, + index: VariantIndex, + accumulator: MergeAccumulator + ): Future[MergeAccumulator] = { + // Try to find existing haplogroup by variants first, then by name + val existingMatch = findExistingMatch(node, index) + + existingMatch match { + case Some(existing) => + // Node exists - check for updates or splits + mergeExistingNode(node, existing, parentId, context, index, accumulator) + + case None => + // New node - create it + createNewNode(node, parentId, context, index, accumulator) + } + } + + /** + * Find an existing haplogroup that matches the input node. + * Primary matching is by variants (including aliases); fallback is by name. + */ + private def findExistingMatch(node: PhyloNodeInput, index: VariantIndex): Option[Haplogroup] = { + // First try variant-based matching - check primary names and all aliases + val allNames = allVariantNames(node.variants) + val variantMatches = allNames + .flatMap(v => index.variantToHaplogroup.getOrElse(v.toUpperCase, Seq.empty)) + .groupBy(identity) + .view.mapValues(_.size) + .toSeq + .sortBy(-_._2) // Sort by match count descending + + // Find haplogroup with most variant matches (>= 1) + variantMatches.headOption.filter(_._2 >= 1).map(_._1).orElse { + // Fallback: match by name + index.haplogroupByName.get(node.name.toUpperCase) + } + } + + /** + * Merge an input node with an existing haplogroup. + */ + private def mergeExistingNode( + node: PhyloNodeInput, + existing: Haplogroup, + parentId: Option[Int], + context: MergeContext, + index: VariantIndex, + accumulator: MergeAccumulator + ): Future[MergeAccumulator] = { + val conflicts = scala.collection.mutable.ListBuffer.empty[MergeConflict] + + // Check for field conflicts + val existingSource = existing.provenance.map(_.primaryCredit).getOrElse(existing.source) + + // Determine if we should update based on conflict strategy + val shouldUpdate = context.conflictStrategy match { + case ConflictStrategy.AlwaysUpdate => true + case ConflictStrategy.KeepExisting => false + case ConflictStrategy.HigherPriorityWins => + getPriority(context.sourceName, context.priorityConfig) < + getPriority(existingSource, context.priorityConfig) + } + + // Check for age estimate conflicts + if (node.formedYbp.isDefined && existing.formedYbp.isDefined && + node.formedYbp != existing.formedYbp) { + conflicts += MergeConflict( + haplogroupName = existing.name, + field = "formedYbp", + existingValue = existing.formedYbp.get.toString, + newValue = node.formedYbp.get.toString, + resolution = if (shouldUpdate) "updated" else "kept_existing", + existingSource = existingSource, + newSource = context.sourceName + ) + } + + for { + // Update provenance to track this merge + _ <- updateProvenance(existing, node.variants, context) + + // Update age estimates if applicable + _ <- if (shouldUpdate && hasAgeEstimates(node)) { + updateAgeEstimates(existing.id.get, node, context.sourceName) + } else { + Future.successful(()) + } + + // Update statistics + updatedStats = if (shouldUpdate && conflicts.nonEmpty) { + accumulator.statistics.copy( + nodesProcessed = accumulator.statistics.nodesProcessed + 1, + nodesUpdated = accumulator.statistics.nodesUpdated + 1 + ) + } else { + accumulator.statistics.copy( + nodesProcessed = accumulator.statistics.nodesProcessed + 1, + nodesUnchanged = accumulator.statistics.nodesUnchanged + 1 + ) + } + + // Recursively process children + childrenResult <- processChildren( + children = node.children, + parentId = existing.id, + context = context, + index = index, + accumulator = accumulator.copy( + statistics = updatedStats, + conflicts = accumulator.conflicts ++ conflicts.toList + ) + ) + } yield childrenResult + } + + /** + * Create a new haplogroup node. + */ + private def createNewNode( + node: PhyloNodeInput, + parentId: Option[Int], + context: MergeContext, + index: VariantIndex, + accumulator: MergeAccumulator + ): Future[MergeAccumulator] = { + // Determine credit - incoming source gets credit for new nodes + val primaryCredit = context.sourceName + val variantNames = primaryVariantNames(node.variants) + val provenance = HaplogroupProvenance.forNewNode(context.sourceName, variantNames) + + val newHaplogroup = Haplogroup( + id = None, + name = node.name, + lineage = None, + description = None, + haplogroupType = context.haplogroupType, + revisionId = 1, + source = context.sourceName, + confidenceLevel = "medium", + validFrom = context.timestamp, + validUntil = None, + formedYbp = node.formedYbp, + formedYbpLower = node.formedYbpLower, + formedYbpUpper = node.formedYbpUpper, + tmrcaYbp = node.tmrcaYbp, + tmrcaYbpLower = node.tmrcaYbpLower, + tmrcaYbpUpper = node.tmrcaYbpUpper, + ageEstimateSource = Some(context.sourceName), + provenance = Some(provenance) + ) + + for { + // Create the haplogroup with parent relationship + newId <- haplogroupRepository.createWithParent(newHaplogroup, parentId, context.sourceName) + + // Associate variants with the new haplogroup + variantCount <- associateVariants(newId, node.variants) + + // Update statistics + updatedStats = accumulator.statistics.copy( + nodesProcessed = accumulator.statistics.nodesProcessed + 1, + nodesCreated = accumulator.statistics.nodesCreated + 1, + variantsAdded = accumulator.statistics.variantsAdded + variantCount, + relationshipsCreated = if (parentId.isDefined) + accumulator.statistics.relationshipsCreated + 1 + else + accumulator.statistics.relationshipsCreated + ) + + // Update index with new haplogroup - include all variant names (primary + aliases) for matching + allVarNames = allVariantNames(node.variants) + updatedIndex = index.copy( + haplogroupByName = index.haplogroupByName + (node.name.toUpperCase -> newHaplogroup.copy(id = Some(newId))), + variantToHaplogroup = allVarNames.foldLeft(index.variantToHaplogroup) { (idx, v) => + idx.updatedWith(v.toUpperCase) { + case Some(hgs) => Some(hgs :+ newHaplogroup.copy(id = Some(newId))) + case None => Some(Seq(newHaplogroup.copy(id = Some(newId)))) + } + } + ) + + // Recursively process children + childrenResult <- processChildren( + children = node.children, + parentId = Some(newId), + context = context, + index = updatedIndex, + accumulator = accumulator.copy(statistics = updatedStats) + ) + } yield childrenResult + } + + /** + * Process child nodes recursively. + */ + private def processChildren( + children: List[PhyloNodeInput], + parentId: Option[Int], + context: MergeContext, + index: VariantIndex, + accumulator: MergeAccumulator + ): Future[MergeAccumulator] = { + children.foldLeft(Future.successful(accumulator)) { (accFuture, child) => + accFuture.flatMap { acc => + mergeNode(child, parentId, context, index, acc) + } + } + } + + /** + * Update provenance for an existing haplogroup. + */ + private def updateProvenance( + existing: Haplogroup, + newVariants: List[VariantInput], + context: MergeContext + ): Future[Boolean] = { + val existingProvenance = existing.provenance.getOrElse( + HaplogroupProvenance(primaryCredit = existing.source, nodeProvenance = Set(existing.source)) + ) + + // Preserve ISOGG credit + val primaryCredit = if (HaplogroupProvenance.shouldPreserveCredit(existingProvenance.primaryCredit)) { + existingProvenance.primaryCredit + } else { + existingProvenance.primaryCredit // Keep existing credit for non-ISOGG too + } + + // Add new source to node provenance + val updatedNodeProv = existingProvenance.nodeProvenance + context.sourceName + + // Add variant provenance for new variants (primary names only for provenance tracking) + val variantNames = primaryVariantNames(newVariants) + val updatedVariantProv = variantNames.foldLeft(existingProvenance.variantProvenance) { (prov, variant) => + prov.updatedWith(variant) { + case Some(sources) => Some(sources + context.sourceName) + case None => Some(Set(context.sourceName)) + } + } + + val updatedProvenance = HaplogroupProvenance( + primaryCredit = primaryCredit, + nodeProvenance = updatedNodeProv, + variantProvenance = updatedVariantProv, + lastMergedAt = Some(context.timestamp), + lastMergedFrom = Some(context.sourceName) + ) + + haplogroupRepository.updateProvenance(existing.id.get, updatedProvenance) + } + + /** + * Update age estimates for a haplogroup. + */ + private def updateAgeEstimates( + haplogroupId: Int, + node: PhyloNodeInput, + sourceName: String + ): Future[Boolean] = { + haplogroupRepository.findById(haplogroupId).flatMap { + case Some(existing) => + val updated = existing.copy( + formedYbp = node.formedYbp.orElse(existing.formedYbp), + formedYbpLower = node.formedYbpLower.orElse(existing.formedYbpLower), + formedYbpUpper = node.formedYbpUpper.orElse(existing.formedYbpUpper), + tmrcaYbp = node.tmrcaYbp.orElse(existing.tmrcaYbp), + tmrcaYbpLower = node.tmrcaYbpLower.orElse(existing.tmrcaYbpLower), + tmrcaYbpUpper = node.tmrcaYbpUpper.orElse(existing.tmrcaYbpUpper), + ageEstimateSource = Some(sourceName) + ) + haplogroupRepository.update(updated) + case None => + Future.successful(false) + } + } + + /** + * Associate variants with a haplogroup, finding or creating variants as needed. + */ + private def associateVariants(haplogroupId: Int, variants: List[VariantInput]): Future[Int] = { + if (variants.isEmpty) { + Future.successful(0) + } else { + // For each variant, find existing variants by primary name and associate them, + // then create alias records for any aliases + Future.traverse(variants) { variantInput => + // First find/associate the primary variant + variantRepository.searchByName(variantInput.name).flatMap { foundVariants => + // Associate all found variants with this haplogroup + val associateFutures = foundVariants.map { variant => + variant.variantId match { + case Some(vid) => + for { + // Associate variant with haplogroup + count <- haplogroupVariantRepository.addVariantToHaplogroup(haplogroupId, vid) + // Create alias records for any aliases from the ISOGG data + _ <- Future.traverse(variantInput.aliases) { alias => + val variantAlias = VariantAlias( + variantId = vid, + aliasType = "common_name", + aliasValue = alias, + source = Some("ISOGG"), + isPrimary = false + ) + variantAliasRepository.addAlias(variantAlias).recover { case _ => false } + } + } yield count + case None => Future.successful(0) + } + } + Future.sequence(associateFutures).map(_.sum) + } + }.map(_.sum) + } + } + + /** + * Get priority for a source (lower = higher priority). + */ + private def getPriority(source: String, config: SourcePriorityConfig): Int = { + config.sourcePriorities.indexOf(source) match { + case -1 => config.defaultPriority + case idx => idx + } + } + + /** + * Check if node has any age estimates. + */ + private def hasAgeEstimates(node: PhyloNodeInput): Boolean = { + node.formedYbp.isDefined || node.tmrcaYbp.isDefined + } + + /** + * Simulate merge without applying changes (for preview). + */ + private def simulateMerge( + sourceTree: PhyloNodeInput, + sourceName: String, + existingIndex: VariantIndex, + priorityConfig: SourcePriorityConfig + ): Future[MergePreviewResponse] = { + // Recursively analyze the tree + val (stats, conflicts, splits, newNodes, updatedNodes, unchangedNodes) = + analyzeTree(sourceTree, existingIndex, sourceName, priorityConfig) + + Future.successful(MergePreviewResponse( + statistics = stats, + conflicts = conflicts, + splits = splits, + newNodes = newNodes, + updatedNodes = updatedNodes, + unchangedNodes = unchangedNodes + )) + } + + /** + * Analyze tree structure for preview without making changes. + */ + private def analyzeTree( + node: PhyloNodeInput, + index: VariantIndex, + sourceName: String, + priorityConfig: SourcePriorityConfig + ): (MergeStatistics, List[MergeConflict], List[SplitOperation], List[String], List[String], List[String]) = { + + val existingMatch = findExistingMatch(node, index) + val conflicts = scala.collection.mutable.ListBuffer.empty[MergeConflict] + val splits = scala.collection.mutable.ListBuffer.empty[SplitOperation] + val newNodes = scala.collection.mutable.ListBuffer.empty[String] + val updatedNodes = scala.collection.mutable.ListBuffer.empty[String] + val unchangedNodes = scala.collection.mutable.ListBuffer.empty[String] + + var stats = existingMatch match { + case Some(existing) => + val existingSource = existing.provenance.map(_.primaryCredit).getOrElse(existing.source) + val shouldUpdate = getPriority(sourceName, priorityConfig) < getPriority(existingSource, priorityConfig) + + // Check for conflicts + if (node.formedYbp.isDefined && existing.formedYbp.isDefined && node.formedYbp != existing.formedYbp) { + conflicts += MergeConflict( + haplogroupName = existing.name, + field = "formedYbp", + existingValue = existing.formedYbp.get.toString, + newValue = node.formedYbp.get.toString, + resolution = if (shouldUpdate) "will_update" else "will_keep_existing", + existingSource = existingSource, + newSource = sourceName + ) + } + + if (shouldUpdate && conflicts.nonEmpty) { + updatedNodes += existing.name + MergeStatistics(1, 0, 1, 0, 0, 0, 0, 0, 0) + } else { + unchangedNodes += existing.name + MergeStatistics(1, 0, 0, 1, 0, 0, 0, 0, 0) + } + + case None => + newNodes += node.name + MergeStatistics(1, 1, 0, 0, node.variants.size, 0, 1, 0, 0) + } + + // Process children + node.children.foreach { child => + val (childStats, childConflicts, childSplits, childNew, childUpdated, childUnchanged) = + analyzeTree(child, index, sourceName, priorityConfig) + stats = MergeStatistics.combine(stats, childStats) + conflicts ++= childConflicts + splits ++= childSplits + newNodes ++= childNew + updatedNodes ++= childUpdated + unchangedNodes ++= childUnchanged + } + + (stats, conflicts.toList, splits.toList, newNodes.toList, updatedNodes.toList, unchangedNodes.toList) + } +} + +// ============================================================================ +// Internal Data Structures +// ============================================================================ + +/** + * Index of existing haplogroups for efficient lookup. + */ +private[services] case class VariantIndex( + variantToHaplogroup: Map[String, Seq[Haplogroup]], + haplogroupByName: Map[String, Haplogroup] +) + +/** + * Context for merge operations. + */ +private[services] case class MergeContext( + haplogroupType: HaplogroupType, + sourceName: String, + priorityConfig: SourcePriorityConfig, + conflictStrategy: ConflictStrategy, + timestamp: LocalDateTime +) + +/** + * Accumulator for merge statistics and results. + */ +private[services] case class MergeAccumulator( + statistics: MergeStatistics, + conflicts: List[MergeConflict], + splits: List[SplitOperation], + errors: List[String] +) + +private[services] object MergeAccumulator { + val empty: MergeAccumulator = MergeAccumulator( + statistics = MergeStatistics.empty, + conflicts = List.empty, + splits = List.empty, + errors = List.empty + ) +} diff --git a/app/services/HaplogroupTreeService.scala b/app/services/HaplogroupTreeService.scala index 5350296..fe49965 100644 --- a/app/services/HaplogroupTreeService.scala +++ b/app/services/HaplogroupTreeService.scala @@ -259,6 +259,22 @@ class HaplogroupTreeService @Inject()( } yield treeLists.flatten } + /** + * Finds and retrieves haplogroup details with all associated genomic variants. + * + * This method fetches the haplogroup (including provenance) and its linked variants. + * + * @param haplogroupName The name of the haplogroup for which details are to be retrieved. + * @param haplogroupType The type of haplogroup (e.g., Y-DNA or mtDNA). + * @return A Future containing a tuple of (Option[Haplogroup], Seq[VariantDTO]). + */ + def findHaplogroupWithVariants(haplogroupName: String, haplogroupType: HaplogroupType): Future[(Option[Haplogroup], Seq[VariantDTO])] = { + for { + haplogroup <- coreRepository.getHaplogroupByName(haplogroupName, haplogroupType) + variants <- findVariantsForHaplogroup(haplogroupName, haplogroupType) + } yield (haplogroup, variants) + } + /** * Finds and retrieves all genomic variants associated with a specified haplogroup. * diff --git a/app/views/curator/haplogroups/detailPanel.scala.html b/app/views/curator/haplogroups/detailPanel.scala.html index 3c0d0e0..e04f678 100644 --- a/app/views/curator/haplogroups/detailPanel.scala.html +++ b/app/views/curator/haplogroups/detailPanel.scala.html @@ -48,6 +48,72 @@
@variant
+
+ @for(src <- sources.toSeq.sorted) {
+ @src
+ }
+
+ @messages("sidebar.noVariants", haplogroupName)
} else { @@ -174,6 +201,65 @@