diff --git a/app/actions/AuthenticatedAction.scala b/app/actions/AuthenticatedAction.scala index aa9318a..1f77b40 100644 --- a/app/actions/AuthenticatedAction.scala +++ b/app/actions/AuthenticatedAction.scala @@ -47,7 +47,7 @@ class AuthenticatedAction @Inject()( * Allows requiring specific roles on top of authentication. */ class RoleAction @Inject()(authService: AuthService)(implicit ec: ExecutionContext) { - + def apply(requiredRoles: String*): ActionFilter[AuthenticatedRequest] = new ActionFilter[AuthenticatedRequest] { override protected def executionContext: ExecutionContext = ec @@ -59,3 +59,21 @@ class RoleAction @Inject()(authService: AuthService)(implicit ec: ExecutionConte } } } + +/** + * PermissionAction builder factory. + * Allows requiring specific permissions on top of authentication. + */ +class PermissionAction @Inject()(authService: AuthService)(implicit ec: ExecutionContext) { + + def apply(permission: String): ActionFilter[AuthenticatedRequest] = new ActionFilter[AuthenticatedRequest] { + override protected def executionContext: ExecutionContext = ec + + override protected def filter[A](request: AuthenticatedRequest[A]): Future[Option[Result]] = { + authService.hasPermission(request.user.id.get, permission).map { hasPermission => + if (hasPermission) None + else Some(Results.Forbidden(s"Missing required permission: $permission")) + } + } + } +} diff --git a/app/actors/YBrowseVariantUpdateActor.scala b/app/actors/YBrowseVariantUpdateActor.scala new file mode 100644 index 0000000..b1e0ff0 --- /dev/null +++ b/app/actors/YBrowseVariantUpdateActor.scala @@ -0,0 +1,122 @@ +package actors + +import config.GenomicsConfig +import org.apache.pekko.actor.Actor +import play.api.Logging +import services.genomics.YBrowseVariantIngestionService + +import java.io.{BufferedInputStream, FileOutputStream} +import java.net.{HttpURLConnection, URI} +import java.nio.file.Files +import scala.concurrent.{ExecutionContext, Future} +import scala.util.{Failure, Success, Try} + +object YBrowseVariantUpdateActor { + case object RunUpdate + case class UpdateResult(success: Boolean, variantsIngested: Int, message: String) +} + +/** + * Actor responsible for downloading and ingesting Y-DNA SNP data from YBrowse. + * + * This actor: + * 1. Downloads the VCF file from ybrowse.org + * 2. Stores it at the configured local path + * 3. Triggers variant ingestion via YBrowseVariantIngestionService + */ +class YBrowseVariantUpdateActor @javax.inject.Inject()( + genomicsConfig: GenomicsConfig, + ingestionService: YBrowseVariantIngestionService +)(implicit ec: ExecutionContext) extends Actor with Logging { + + import YBrowseVariantUpdateActor.* + + override def receive: Receive = { + case RunUpdate => + logger.info("YBrowseVariantUpdateActor: Starting YBrowse variant update") + val senderRef = sender() + + runUpdate().onComplete { + case Success(result) => + logger.info(s"YBrowseVariantUpdateActor: Update completed - ${result.message}") + senderRef ! result + case Failure(ex) => + logger.error(s"YBrowseVariantUpdateActor: Update failed - ${ex.getMessage}", ex) + senderRef ! UpdateResult(success = false, variantsIngested = 0, s"Update failed: ${ex.getMessage}") + } + } + + private def runUpdate(): Future[UpdateResult] = { + Future { + downloadVcfFile() + }.flatMap { + case Success(_) => + logger.info("VCF file downloaded successfully, starting ingestion") + ingestionService.ingestVcf(genomicsConfig.ybrowseVcfStoragePath).map { count => + UpdateResult(success = true, variantsIngested = count, s"Successfully ingested $count variants") + } + case Failure(ex) => + Future.successful(UpdateResult(success = false, variantsIngested = 0, s"Download failed: ${ex.getMessage}")) + } + } + + private def downloadVcfFile(): Try[Unit] = Try { + val url = URI.create(genomicsConfig.ybrowseVcfUrl).toURL + val targetFile = genomicsConfig.ybrowseVcfStoragePath + + // Ensure parent directory exists + val parentDir = targetFile.getParentFile + if (parentDir != null && !parentDir.exists()) { + Files.createDirectories(parentDir.toPath) + logger.info(s"Created directory: ${parentDir.getAbsolutePath}") + } + + // Download to a temp file first, then rename (atomic operation) + val tempFile = new java.io.File(targetFile.getAbsolutePath + ".tmp") + + logger.info(s"Downloading VCF from ${genomicsConfig.ybrowseVcfUrl} to ${tempFile.getAbsolutePath}") + + val connection = url.openConnection().asInstanceOf[HttpURLConnection] + connection.setRequestMethod("GET") + connection.setConnectTimeout(30000) // 30 seconds + connection.setReadTimeout(300000) // 5 minutes for large file + + try { + val responseCode = connection.getResponseCode + if (responseCode != HttpURLConnection.HTTP_OK) { + throw new RuntimeException(s"HTTP request failed with status $responseCode") + } + + val inputStream = new BufferedInputStream(connection.getInputStream) + val outputStream = new FileOutputStream(tempFile) + + try { + val buffer = new Array[Byte](8192) + var bytesRead = 0 + var totalBytes = 0L + + while ({ bytesRead = inputStream.read(buffer); bytesRead != -1 }) { + outputStream.write(buffer, 0, bytesRead) + totalBytes += bytesRead + } + + logger.info(s"Downloaded $totalBytes bytes") + } finally { + inputStream.close() + outputStream.close() + } + + // Atomic rename + if (targetFile.exists()) { + targetFile.delete() + } + if (!tempFile.renameTo(targetFile)) { + throw new RuntimeException(s"Failed to rename temp file to ${targetFile.getAbsolutePath}") + } + + logger.info(s"VCF file saved to ${targetFile.getAbsolutePath}") + } finally { + connection.disconnect() + } + } +} diff --git a/app/config/FeatureFlags.scala b/app/config/FeatureFlags.scala new file mode 100644 index 0000000..3520042 --- /dev/null +++ b/app/config/FeatureFlags.scala @@ -0,0 +1,20 @@ +package config + +import jakarta.inject.{Inject, Singleton} +import play.api.Configuration + +/** + * Configuration wrapper for feature flags. + * Allows features to be enabled/disabled via application.conf. + */ +@Singleton +class FeatureFlags @Inject()(config: Configuration) { + + private val featuresConfig = config.getOptional[Configuration]("features").getOrElse(Configuration.empty) + + /** + * Show branch age estimates (Formed/TMRCA dates) on tree nodes. + * Disabled by default until age data is populated. + */ + val showBranchAgeEstimates: Boolean = featuresConfig.getOptional[Boolean]("tree.showBranchAgeEstimates").getOrElse(false) +} diff --git a/app/config/GenomicsConfig.scala b/app/config/GenomicsConfig.scala new file mode 100644 index 0000000..41dfa19 --- /dev/null +++ b/app/config/GenomicsConfig.scala @@ -0,0 +1,49 @@ +package config + +import jakarta.inject.{Inject, Singleton} +import play.api.Configuration + +import java.io.File + +/** + * Configuration wrapper for genomics-related settings. + */ +@Singleton +class GenomicsConfig @Inject()(config: Configuration) { + + private val genomicsConfig = config.get[Configuration]("genomics") + + val supportedReferences: Seq[String] = genomicsConfig.get[Seq[String]]("references.supported") + + val referenceAliases: Map[String, String] = genomicsConfig.getOptional[Map[String, String]]("references.aliases").getOrElse(Map.empty) + + val fastaPaths: Map[String, File] = genomicsConfig.get[Map[String, String]]("references.fasta_paths").map { + case (genome, path) => genome -> new File(path) + } + + // YBrowse configuration + val ybrowseVcfUrl: String = genomicsConfig.get[String]("ybrowse.vcf_url") + val ybrowseVcfStoragePath: File = new File(genomicsConfig.get[String]("ybrowse.vcf_storage_path")) + + /** + * Retrieves the path to a liftover chain file for a given source and target genome. + * + * @param source The source reference genome (e.g., "GRCh38"). + * @param target The target reference genome (e.g., "GRCh37"). + * @return An Option containing the File if the chain is configured and exists, otherwise None. + */ + def getLiftoverChainFile(source: String, target: String): Option[File] = { + val key = s"$source->$target" + genomicsConfig.getOptional[String](s"liftover.chains.\"$key\"").map(new File(_)) + } + + /** + * Resolves a genome name to its canonical form using the aliases configuration. + * + * @param name The input genome name. + * @return The canonical name if an alias exists, otherwise the input name. + */ + def resolveReferenceName(name: String): String = { + referenceAliases.getOrElse(name, name) + } +} diff --git a/app/controllers/CuratorController.scala b/app/controllers/CuratorController.scala new file mode 100644 index 0000000..9882d23 --- /dev/null +++ b/app/controllers/CuratorController.scala @@ -0,0 +1,872 @@ +package controllers + +import actions.{AuthenticatedAction, AuthenticatedRequest, PermissionAction} +import jakarta.inject.{Inject, Singleton} +import models.HaplogroupType +import models.dal.domain.genomics.Variant +import models.domain.genomics.{VariantGroup, VariantWithContig} +import models.domain.haplogroups.Haplogroup +import org.webjars.play.WebJarsUtil +import play.api.Logging +import play.api.data.Form +import play.api.data.Forms.* +import play.api.i18n.I18nSupport +import play.api.mvc.* +import repositories.{GenbankContigRepository, HaplogroupCoreRepository, HaplogroupVariantRepository, VariantAliasRepository, VariantRepository} +import services.{CuratorAuditService, TreeRestructuringService} +import services.genomics.YBrowseVariantIngestionService + +import java.time.LocalDateTime +import scala.concurrent.{ExecutionContext, Future} + +case class HaplogroupFormData( + name: String, + lineage: Option[String], + description: Option[String], + haplogroupType: String, + source: String, + confidenceLevel: String, + formedYbp: Option[Int], + formedYbpLower: Option[Int], + formedYbpUpper: Option[Int], + tmrcaYbp: Option[Int], + tmrcaYbpLower: Option[Int], + tmrcaYbpUpper: Option[Int], + ageEstimateSource: Option[String] +) + +case class CreateHaplogroupFormData( + name: String, + lineage: Option[String], + description: Option[String], + haplogroupType: String, + source: String, + confidenceLevel: String, + parentId: Option[Int], + createAboveRoot: Boolean +) + +case class VariantFormData( + genbankContigId: Int, + position: Int, + referenceAllele: String, + alternateAllele: String, + variantType: String, + rsId: Option[String], + commonName: Option[String] +) + +case class SplitBranchFormData( + name: String, + lineage: Option[String], + description: Option[String], + source: String, + confidenceLevel: String, + variantGroupKeys: Seq[String], + childIds: Seq[Int] +) + +@Singleton +class CuratorController @Inject()( + val controllerComponents: ControllerComponents, + authenticatedAction: AuthenticatedAction, + permissionAction: PermissionAction, + haplogroupRepository: HaplogroupCoreRepository, + variantRepository: VariantRepository, + variantAliasRepository: VariantAliasRepository, + haplogroupVariantRepository: HaplogroupVariantRepository, + genbankContigRepository: GenbankContigRepository, + auditService: CuratorAuditService, + treeRestructuringService: TreeRestructuringService, + variantIngestionService: YBrowseVariantIngestionService +)(implicit ec: ExecutionContext, webJarsUtil: WebJarsUtil) + extends BaseController with I18nSupport with Logging { + + // Permission-based action composition + private def withPermission(permission: String) = + authenticatedAction andThen permissionAction(permission) + + // Forms + private val haplogroupForm: Form[HaplogroupFormData] = Form( + mapping( + "name" -> nonEmptyText(1, 100), + "lineage" -> optional(text(maxLength = 500)), + "description" -> optional(text(maxLength = 2000)), + "haplogroupType" -> nonEmptyText.verifying("Invalid type", t => HaplogroupType.fromString(t).isDefined), + "source" -> nonEmptyText(1, 100), + "confidenceLevel" -> nonEmptyText(1, 50), + "formedYbp" -> optional(number), + "formedYbpLower" -> optional(number), + "formedYbpUpper" -> optional(number), + "tmrcaYbp" -> optional(number), + "tmrcaYbpLower" -> optional(number), + "tmrcaYbpUpper" -> optional(number), + "ageEstimateSource" -> optional(text(maxLength = 100)) + )(HaplogroupFormData.apply)(h => Some((h.name, h.lineage, h.description, h.haplogroupType, h.source, h.confidenceLevel, h.formedYbp, h.formedYbpLower, h.formedYbpUpper, h.tmrcaYbp, h.tmrcaYbpLower, h.tmrcaYbpUpper, h.ageEstimateSource))) + ) + + private val variantForm: Form[VariantFormData] = Form( + mapping( + "genbankContigId" -> number, + "position" -> number, + "referenceAllele" -> nonEmptyText(1, 1000), + "alternateAllele" -> nonEmptyText(1, 1000), + "variantType" -> nonEmptyText(1, 50), + "rsId" -> optional(text(maxLength = 50)), + "commonName" -> optional(text(maxLength = 100)) + )(VariantFormData.apply)(v => Some((v.genbankContigId, v.position, v.referenceAllele, v.alternateAllele, v.variantType, v.rsId, v.commonName))) + ) + + private val splitBranchForm: Form[SplitBranchFormData] = Form( + mapping( + "name" -> nonEmptyText(1, 100), + "lineage" -> optional(text(maxLength = 500)), + "description" -> optional(text(maxLength = 2000)), + "source" -> nonEmptyText(1, 100), + "confidenceLevel" -> nonEmptyText(1, 50), + "variantGroupKeys" -> seq(text), + "childIds" -> seq(number) + )(SplitBranchFormData.apply)(s => Some((s.name, s.lineage, s.description, s.source, s.confidenceLevel, s.variantGroupKeys, s.childIds))) + ) + + private val createHaplogroupFormMapping: Form[CreateHaplogroupFormData] = Form( + mapping( + "name" -> nonEmptyText(1, 100), + "lineage" -> optional(text(maxLength = 500)), + "description" -> optional(text(maxLength = 2000)), + "haplogroupType" -> nonEmptyText.verifying("Invalid type", t => HaplogroupType.fromString(t).isDefined), + "source" -> nonEmptyText(1, 100), + "confidenceLevel" -> nonEmptyText(1, 50), + "parentId" -> optional(number), + "createAboveRoot" -> boolean + )(CreateHaplogroupFormData.apply)(c => Some((c.name, c.lineage, c.description, c.haplogroupType, c.source, c.confidenceLevel, c.parentId, c.createAboveRoot))) + ) + + // === Dashboard === + + def dashboard: Action[AnyContent] = withPermission("haplogroup.view").async { implicit request => + for { + yCount <- haplogroupRepository.countByType(HaplogroupType.Y) + mtCount <- haplogroupRepository.countByType(HaplogroupType.MT) + variantCount <- variantRepository.count(None) + } yield { + Ok(views.html.curator.dashboard(yCount, mtCount, variantCount)) + } + } + + // === Haplogroups === + + def listHaplogroups(query: Option[String], hgType: Option[String], page: Int, pageSize: Int): Action[AnyContent] = + withPermission("haplogroup.view").async { implicit request => + val haplogroupType = hgType.flatMap(HaplogroupType.fromString) + val offset = (page - 1) * pageSize + + for { + haplogroups <- query match { + case Some(q) if q.nonEmpty => haplogroupRepository.search(q, haplogroupType, pageSize, offset) + case _ => haplogroupRepository.search("", haplogroupType, pageSize, offset) + } + totalCount <- haplogroupRepository.count(query.filter(_.nonEmpty), haplogroupType) + } yield { + val totalPages = Math.max(1, (totalCount + pageSize - 1) / pageSize) + Ok(views.html.curator.haplogroups.list(haplogroups, query, hgType, page, totalPages, pageSize)) + } + } + + def haplogroupsFragment(query: Option[String], hgType: Option[String], page: Int, pageSize: Int): Action[AnyContent] = + withPermission("haplogroup.view").async { implicit request => + val haplogroupType = hgType.flatMap(HaplogroupType.fromString) + val offset = (page - 1) * pageSize + + for { + haplogroups <- query match { + case Some(q) if q.nonEmpty => haplogroupRepository.search(q, haplogroupType, pageSize, offset) + case _ => haplogroupRepository.search("", haplogroupType, pageSize, offset) + } + totalCount <- haplogroupRepository.count(query.filter(_.nonEmpty), haplogroupType) + } yield { + val totalPages = Math.max(1, (totalCount + pageSize - 1) / pageSize) + Ok(views.html.curator.haplogroups.listFragment(haplogroups, query, hgType, page, totalPages, pageSize)) + } + } + + def haplogroupDetailPanel(id: Int): Action[AnyContent] = + withPermission("haplogroup.view").async { implicit request => + for { + haplogroupOpt <- haplogroupRepository.findById(id) + parentOpt <- haplogroupRepository.getParent(id) + children <- haplogroupRepository.getDirectChildren(id) + variants <- haplogroupVariantRepository.getHaplogroupVariants(id) + history <- auditService.getHaplogroupHistory(id) + } yield { + val variantsWithContig = variants.map { case (v, c) => VariantWithContig(v, c) } + val variantGroups = variantRepository.groupVariants(variantsWithContig) + haplogroupOpt match { + case Some(haplogroup) => + Ok(views.html.curator.haplogroups.detailPanel(haplogroup, parentOpt, children, variantGroups, history)) + case None => + NotFound("Haplogroup not found") + } + } + } + + def searchHaplogroupsJson(query: Option[String], hgType: Option[String]): Action[AnyContent] = + withPermission("haplogroup.view").async { implicit request => + import play.api.libs.json.* + val haplogroupType = hgType.flatMap(HaplogroupType.fromString) + for { + haplogroups <- haplogroupRepository.search(query.getOrElse(""), haplogroupType, 100, 0) + } yield { + val json = haplogroups.map { h => + Json.obj( + "id" -> h.id, + "name" -> h.name, + "type" -> h.haplogroupType.toString + ) + } + Ok(Json.toJson(json)) + } + } + + def createHaplogroupForm: Action[AnyContent] = + withPermission("haplogroup.create").async { implicit request => + for { + yRoots <- haplogroupRepository.findRoots(HaplogroupType.Y) + mtRoots <- haplogroupRepository.findRoots(HaplogroupType.MT) + } yield { + Ok(views.html.curator.haplogroups.createForm(createHaplogroupFormMapping, yRoots, mtRoots)) + } + } + + def createHaplogroup: Action[AnyContent] = + withPermission("haplogroup.create").async { implicit request => + createHaplogroupFormMapping.bindFromRequest().fold( + formWithErrors => { + for { + yRoots <- haplogroupRepository.findRoots(HaplogroupType.Y) + mtRoots <- haplogroupRepository.findRoots(HaplogroupType.MT) + } yield BadRequest(views.html.curator.haplogroups.createForm(formWithErrors, yRoots, mtRoots)) + }, + data => { + val haplogroupType = HaplogroupType.fromString(data.haplogroupType).get + val haplogroup = Haplogroup( + id = None, + name = data.name, + lineage = data.lineage, + description = data.description, + haplogroupType = haplogroupType, + revisionId = 1, + source = data.source, + confidenceLevel = data.confidenceLevel, + validFrom = LocalDateTime.now(), + validUntil = None + ) + + for { + // Validate parent selection + yRoots <- haplogroupRepository.findRoots(HaplogroupType.Y) + mtRoots <- haplogroupRepository.findRoots(HaplogroupType.MT) + existingRoots = if (haplogroupType == HaplogroupType.Y) yRoots else mtRoots + + result <- (data.parentId, data.createAboveRoot, existingRoots.nonEmpty) match { + case (None, true, true) => + // Create as NEW root above existing roots + for { + newId <- haplogroupRepository.createWithParent(haplogroup, None, "curator-create-above-root") + createdHaplogroup = haplogroup.copy(id = Some(newId)) + // Re-parent all existing roots to become children of the new root + _ <- Future.traverse(existingRoots.flatMap(_.id)) { oldRootId => + haplogroupRepository.updateParent(oldRootId, newId, "curator-create-above-root") + } + _ <- auditService.logHaplogroupCreate( + request.user.id.get, + createdHaplogroup, + Some(s"Created as new root above existing root(s): ${existingRoots.map(_.name).mkString(", ")}") + ) + } yield { + Redirect(routes.CuratorController.listHaplogroups(None, None, 1, 20)) + .flashing("success" -> s"Haplogroup '${data.name}' created as new root. Previous root(s) are now children.") + } + + case (None, false, true) => + // Trying to create a new root when one already exists without the flag + val errorForm = createHaplogroupFormMapping.fill(data).withGlobalError( + s"A root haplogroup already exists for ${haplogroupType}. Select a parent (leaf), use 'Create above existing root', or use Split to create a subclade." + ) + Future.successful(BadRequest(views.html.curator.haplogroups.createForm(errorForm, yRoots, mtRoots))) + + case (Some(parentId), _, _) => + // Validate parent exists and is of the same type + haplogroupRepository.findById(parentId).flatMap { + case Some(parent) if parent.haplogroupType != haplogroupType => + val errorForm = createHaplogroupFormMapping.fill(data).withGlobalError( + s"Parent haplogroup type (${parent.haplogroupType}) must match the new haplogroup type (${haplogroupType})" + ) + Future.successful(BadRequest(views.html.curator.haplogroups.createForm(errorForm, yRoots, mtRoots))) + + case Some(_) => + // Create with parent (leaf) + for { + newId <- haplogroupRepository.createWithParent(haplogroup, Some(parentId), "curator-create") + createdHaplogroup = haplogroup.copy(id = Some(newId)) + _ <- auditService.logHaplogroupCreate(request.user.id.get, createdHaplogroup, Some("Created as leaf via curator interface")) + } yield { + Redirect(routes.CuratorController.listHaplogroups(None, None, 1, 20)) + .flashing("success" -> s"Haplogroup '${data.name}' created successfully as child of parent") + } + + case None => + val errorForm = createHaplogroupFormMapping.fill(data).withGlobalError("Selected parent haplogroup not found") + Future.successful(BadRequest(views.html.curator.haplogroups.createForm(errorForm, yRoots, mtRoots))) + } + + case (None, _, false) => + // Create as new root (no existing roots for this type) + for { + newId <- haplogroupRepository.createWithParent(haplogroup, None, "curator-create") + createdHaplogroup = haplogroup.copy(id = Some(newId)) + _ <- auditService.logHaplogroupCreate(request.user.id.get, createdHaplogroup, Some("Created as root via curator interface")) + } yield { + Redirect(routes.CuratorController.listHaplogroups(None, None, 1, 20)) + .flashing("success" -> s"Haplogroup '${data.name}' created successfully as root") + } + } + } yield result + } + ) + } + + def editHaplogroupForm(id: Int): Action[AnyContent] = + withPermission("haplogroup.update").async { implicit request => + haplogroupRepository.findById(id).map { + case Some(haplogroup) => + val formData = HaplogroupFormData( + name = haplogroup.name, + lineage = haplogroup.lineage, + description = haplogroup.description, + haplogroupType = haplogroup.haplogroupType.toString, + source = haplogroup.source, + confidenceLevel = haplogroup.confidenceLevel, + formedYbp = haplogroup.formedYbp, + formedYbpLower = haplogroup.formedYbpLower, + formedYbpUpper = haplogroup.formedYbpUpper, + tmrcaYbp = haplogroup.tmrcaYbp, + tmrcaYbpLower = haplogroup.tmrcaYbpLower, + tmrcaYbpUpper = haplogroup.tmrcaYbpUpper, + ageEstimateSource = haplogroup.ageEstimateSource + ) + Ok(views.html.curator.haplogroups.editForm(id, haplogroupForm.fill(formData))) + case None => + NotFound("Haplogroup not found") + } + } + + def updateHaplogroup(id: Int): Action[AnyContent] = + withPermission("haplogroup.update").async { implicit request => + haplogroupRepository.findById(id).flatMap { + case Some(oldHaplogroup) => + haplogroupForm.bindFromRequest().fold( + formWithErrors => { + Future.successful(BadRequest(views.html.curator.haplogroups.editForm(id, formWithErrors))) + }, + data => { + val updatedHaplogroup = oldHaplogroup.copy( + name = data.name, + lineage = data.lineage, + description = data.description, + source = data.source, + confidenceLevel = data.confidenceLevel, + formedYbp = data.formedYbp, + formedYbpLower = data.formedYbpLower, + formedYbpUpper = data.formedYbpUpper, + tmrcaYbp = data.tmrcaYbp, + tmrcaYbpLower = data.tmrcaYbpLower, + tmrcaYbpUpper = data.tmrcaYbpUpper, + ageEstimateSource = data.ageEstimateSource + ) + + for { + updated <- haplogroupRepository.update(updatedHaplogroup) + _ <- if (updated) { + auditService.logHaplogroupUpdate(request.user.id.get, oldHaplogroup, updatedHaplogroup, Some("Updated via curator interface")) + } else { + Future.successful(()) + } + } yield { + if (updated) { + Redirect(routes.CuratorController.listHaplogroups(None, None, 1, 20)) + .flashing("success" -> s"Haplogroup '${data.name}' updated successfully") + } else { + BadRequest("Failed to update haplogroup") + } + } + } + ) + case None => + Future.successful(NotFound("Haplogroup not found")) + } + } + + def deleteHaplogroup(id: Int): Action[AnyContent] = + withPermission("haplogroup.delete").async { implicit request => + haplogroupRepository.findById(id).flatMap { + case Some(haplogroup) => + for { + deleted <- haplogroupRepository.softDelete(id, "curator-deletion") + _ <- if (deleted) { + auditService.logHaplogroupDelete(request.user.id.get, haplogroup, Some("Soft-deleted via curator interface")) + } else { + Future.successful(()) + } + } yield { + if (deleted) { + Ok("Deleted").withHeaders("HX-Trigger" -> "haplogroupDeleted") + } else { + BadRequest("Failed to delete haplogroup") + } + } + case None => + Future.successful(NotFound("Haplogroup not found")) + } + } + + // === Variants === + + def listVariants(query: Option[String], page: Int, pageSize: Int): Action[AnyContent] = + withPermission("variant.view").async { implicit request => + for { + // Fetch grouped variants - note: pagination is approximate since we group after fetching + variantGroups <- variantRepository.searchGrouped(query.getOrElse(""), pageSize * 3) // Fetch extra to ensure we have enough groups + } yield { + val pagedGroups = variantGroups.drop((page - 1) * pageSize).take(pageSize) + val totalPages = Math.max(1, (variantGroups.size + pageSize - 1) / pageSize) + Ok(views.html.curator.variants.list(pagedGroups, query, page, totalPages, pageSize)) + } + } + + def variantsFragment(query: Option[String], page: Int, pageSize: Int): Action[AnyContent] = + withPermission("variant.view").async { implicit request => + for { + variantGroups <- variantRepository.searchGrouped(query.getOrElse(""), pageSize * 3) + } yield { + val pagedGroups = variantGroups.drop((page - 1) * pageSize).take(pageSize) + val totalPages = Math.max(1, (variantGroups.size + pageSize - 1) / pageSize) + Ok(views.html.curator.variants.listFragment(pagedGroups, query, page, totalPages, pageSize)) + } + } + + def variantDetailPanel(id: Int): Action[AnyContent] = + withPermission("variant.view").async { implicit request => + for { + variantOpt <- variantRepository.findByIdWithContig(id) + // Get all variants in the same group + allVariantsInGroup <- variantOpt match { + case Some(vwc) => + val groupKey = vwc.variant.commonName.orElse(vwc.variant.rsId).getOrElse(s"variant_${id}") + variantRepository.getVariantsByGroupKey(groupKey) + case None => Future.successful(Seq.empty) + } + // Fetch aliases for this variant + aliases <- variantAliasRepository.findByVariantId(id) + haplogroups <- haplogroupVariantRepository.getHaplogroupsByVariant(id) + history <- auditService.getVariantHistory(id) + } yield { + variantOpt match { + case Some(variantWithContig) => + val variantGroup = variantRepository.groupVariants(allVariantsInGroup).headOption + Ok(views.html.curator.variants.detailPanel(variantWithContig, variantGroup, aliases, haplogroups, history)) + case None => + NotFound("Variant not found") + } + } + } + + def createVariantForm: Action[AnyContent] = + withPermission("variant.create").async { implicit request => + genbankContigRepository.getYAndMtContigs.map { contigs => + Ok(views.html.curator.variants.createForm(variantForm, contigs)) + } + } + + def createVariant: Action[AnyContent] = + withPermission("variant.create").async { implicit request => + variantForm.bindFromRequest().fold( + formWithErrors => { + genbankContigRepository.getYAndMtContigs.map { contigs => + BadRequest(views.html.curator.variants.createForm(formWithErrors, contigs)) + } + }, + data => { + val variant = Variant( + variantId = None, + genbankContigId = data.genbankContigId, + position = data.position, + referenceAllele = data.referenceAllele, + alternateAllele = data.alternateAllele, + variantType = data.variantType, + rsId = data.rsId, + commonName = data.commonName + ) + + for { + // Create the source variant + newId <- variantRepository.createVariant(variant) + createdVariant = variant.copy(variantId = Some(newId)) + _ <- auditService.logVariantCreate(request.user.id.get, createdVariant, Some("Created via curator interface")) + + // Get the source contig for liftover + sourceContigOpt <- genbankContigRepository.findById(data.genbankContigId) + + // Attempt liftover to other reference genomes + liftedCount <- sourceContigOpt match { + case Some(sourceContig) => + variantIngestionService.liftoverVariant(createdVariant, sourceContig).flatMap { liftedVariants => + if (liftedVariants.nonEmpty) { + logger.info(s"Lifting variant ${data.commonName.getOrElse("unnamed")} to ${liftedVariants.size} other reference(s)") + // Create or find each lifted variant + variantRepository.findOrCreateVariantsBatch(liftedVariants).map(_.size) + } else { + Future.successful(0) + } + } + case None => + logger.warn(s"Source contig ${data.genbankContigId} not found for liftover") + Future.successful(0) + } + } yield { + val message = if (liftedCount > 0) { + s"Variant created successfully. Also lifted to $liftedCount other reference genome(s)." + } else { + s"Variant created successfully. (Liftover to other references not available or failed)" + } + Redirect(routes.CuratorController.listVariants(None, 1, 20)) + .flashing("success" -> message) + } + } + ) + } + + def editVariantForm(id: Int): Action[AnyContent] = + withPermission("variant.update").async { implicit request => + variantRepository.findById(id).map { + case Some(variant) => + val formData = VariantFormData( + genbankContigId = variant.genbankContigId, + position = variant.position, + referenceAllele = variant.referenceAllele, + alternateAllele = variant.alternateAllele, + variantType = variant.variantType, + rsId = variant.rsId, + commonName = variant.commonName + ) + Ok(views.html.curator.variants.editForm(id, variantForm.fill(formData))) + case None => + NotFound("Variant not found") + } + } + + def updateVariant(id: Int): Action[AnyContent] = + withPermission("variant.update").async { implicit request => + variantRepository.findById(id).flatMap { + case Some(oldVariant) => + variantForm.bindFromRequest().fold( + formWithErrors => { + Future.successful(BadRequest(views.html.curator.variants.editForm(id, formWithErrors))) + }, + data => { + val updatedVariant = oldVariant.copy( + variantType = data.variantType, + rsId = data.rsId, + commonName = data.commonName + ) + + for { + updated <- variantRepository.update(updatedVariant) + _ <- if (updated) { + auditService.logVariantUpdate(request.user.id.get, oldVariant, updatedVariant, Some("Updated via curator interface")) + } else { + Future.successful(()) + } + } yield { + if (updated) { + Redirect(routes.CuratorController.listVariants(None, 1, 20)) + .flashing("success" -> "Variant updated successfully") + } else { + BadRequest("Failed to update variant") + } + } + } + ) + case None => + Future.successful(NotFound("Variant not found")) + } + } + + def editVariantGroupForm(groupKey: String): Action[AnyContent] = + withPermission("variant.update").async { implicit request => + variantRepository.getVariantsByGroupKey(groupKey).map { variants => + if (variants.isEmpty) { + NotFound("Variant group not found") + } else { + val variantGroup = variantRepository.groupVariants(variants).head + // Use shared values from group for form + val formData = VariantFormData( + genbankContigId = variants.head.variant.genbankContigId, + position = variants.head.variant.position, + referenceAllele = variants.head.variant.referenceAllele, + alternateAllele = variants.head.variant.alternateAllele, + variantType = variants.head.variant.variantType, + rsId = variantGroup.rsId, + commonName = variantGroup.commonName + ) + Ok(views.html.curator.variants.editGroupForm(groupKey, variantGroup, variantForm.fill(formData))) + } + } + } + + def updateVariantGroup(groupKey: String): Action[AnyContent] = + withPermission("variant.update").async { implicit request => + variantRepository.getVariantsByGroupKey(groupKey).flatMap { variants => + if (variants.isEmpty) { + Future.successful(NotFound("Variant group not found")) + } else { + val variantGroup = variantRepository.groupVariants(variants).head + variantForm.bindFromRequest().fold( + formWithErrors => { + Future.successful(BadRequest(views.html.curator.variants.editGroupForm(groupKey, variantGroup, formWithErrors))) + }, + data => { + // Update all variants in the group with the shared fields + val updateFutures = variants.map { vwc => + val oldVariant = vwc.variant + val updatedVariant = oldVariant.copy( + variantType = data.variantType, + rsId = data.rsId, + commonName = data.commonName + ) + for { + updated <- variantRepository.update(updatedVariant) + _ <- if (updated) { + auditService.logVariantUpdate(request.user.id.get, oldVariant, updatedVariant, Some(s"Updated via group edit ($groupKey)")) + } else { + Future.successful(()) + } + } yield updated + } + + Future.sequence(updateFutures).map { results => + if (results.forall(identity)) { + Redirect(routes.CuratorController.listVariants(None, 1, 20)) + .flashing("success" -> s"Updated ${results.size} variants in group $groupKey") + } else { + BadRequest(s"Failed to update some variants in group") + } + } + } + ) + } + } + } + + def deleteVariant(id: Int): Action[AnyContent] = + withPermission("variant.delete").async { implicit request => + variantRepository.findById(id).flatMap { + case Some(variant) => + for { + deleted <- variantRepository.delete(id) + _ <- if (deleted) { + auditService.logVariantDelete(request.user.id.get, variant, Some("Deleted via curator interface")) + } else { + Future.successful(()) + } + } yield { + if (deleted) { + Ok("Deleted").withHeaders("HX-Trigger" -> "variantDeleted") + } else { + BadRequest("Failed to delete variant") + } + } + case None => + Future.successful(NotFound("Variant not found")) + } + } + + // === Audit === + + def auditHistory(entityType: String, entityId: Int): Action[AnyContent] = + withPermission("audit.view").async { implicit request => + val historyFuture = entityType match { + case "haplogroup" => auditService.getHaplogroupHistory(entityId) + case "variant" => auditService.getVariantHistory(entityId) + case _ => Future.successful(Seq.empty) + } + + historyFuture.map { history => + Ok(views.html.curator.audit.historyPanel(entityType, entityId, history)) + } + } + + // === Haplogroup-Variant Associations === + + def searchVariantsForHaplogroup(haplogroupId: Int, query: Option[String]): Action[AnyContent] = + withPermission("haplogroup.view").async { implicit request => + for { + haplogroupOpt <- haplogroupRepository.findById(haplogroupId) + variantGroups <- query match { + case Some(q) if q.nonEmpty => variantRepository.searchGrouped(q, 20) + case _ => Future.successful(Seq.empty) + } + existingVariantIds <- haplogroupVariantRepository.getVariantsByHaplogroup(haplogroupId).map(_.flatMap(_.variantId).toSet) + } yield { + // Filter out groups where ALL variants are already associated + val availableGroups = variantGroups.filterNot { group => + group.variantIds.forall(existingVariantIds.contains) + } + + haplogroupOpt match { + case Some(haplogroup) => + Ok(views.html.curator.haplogroups.variantSearchResults(haplogroupId, haplogroup.name, query, availableGroups)) + case None => + NotFound("Haplogroup not found") + } + } + } + + def addVariantGroupToHaplogroup(haplogroupId: Int, groupKey: String): Action[AnyContent] = + withPermission("haplogroup.update").async { implicit request => + for { + // Get all variants in the group + variantsInGroup <- variantRepository.getVariantsByGroupKey(groupKey) + existingVariantIds <- haplogroupVariantRepository.getVariantsByHaplogroup(haplogroupId).map(_.flatMap(_.variantId).toSet) + + // Add each variant that isn't already associated + addedIds <- Future.traverse(variantsInGroup.filterNot(v => existingVariantIds.contains(v.variant.variantId.getOrElse(-1)))) { vwc => + for { + hvId <- haplogroupVariantRepository.addVariantToHaplogroup(haplogroupId, vwc.variant.variantId.get) + _ <- auditService.logVariantAddedToHaplogroup( + request.user.email.getOrElse(request.user.id.map(_.toString).getOrElse("unknown")), + hvId, + Some(s"Added variant ${vwc.variant.variantId.get} (${groupKey}) to haplogroup $haplogroupId") + ) + } yield hvId + } + + // Fetch updated variants for display + variants <- haplogroupVariantRepository.getHaplogroupVariants(haplogroupId) + variantsWithContig = variants.map { case (v, c) => VariantWithContig(v, c) } + variantGroups = variantRepository.groupVariants(variantsWithContig) + } yield { + Ok(views.html.curator.haplogroups.variantsPanel(haplogroupId, variantGroups)) + .withHeaders("HX-Trigger" -> "variantAdded") + } + } + + def removeVariantGroupFromHaplogroup(haplogroupId: Int, groupKey: String): Action[AnyContent] = + withPermission("haplogroup.update").async { implicit request => + for { + // Get all variants in the group + variantsInGroup <- variantRepository.getVariantsByGroupKey(groupKey) + + // Remove each variant + removed <- Future.traverse(variantsInGroup.flatMap(_.variant.variantId)) { variantId => + haplogroupVariantRepository.removeVariantFromHaplogroup(haplogroupId, variantId) + } + + // Fetch updated variants for display + variants <- haplogroupVariantRepository.getHaplogroupVariants(haplogroupId) + variantsWithContig = variants.map { case (v, c) => VariantWithContig(v, c) } + variantGroups = variantRepository.groupVariants(variantsWithContig) + } yield { + if (removed.sum > 0) { + Ok(views.html.curator.haplogroups.variantsPanel(haplogroupId, variantGroups)) + .withHeaders("HX-Trigger" -> "variantRemoved") + } else { + BadRequest("Failed to remove variant group") + } + } + } + + def haplogroupVariantHistory(haplogroupVariantId: Int): Action[AnyContent] = + withPermission("audit.view").async { implicit request => + auditService.getHaplogroupVariantHistory(haplogroupVariantId).map { history => + Ok(views.html.curator.haplogroups.variantHistoryPanel(haplogroupVariantId, history)) + } + } + + // === Tree Restructuring === + + def splitBranchForm(parentId: Int): Action[AnyContent] = + withPermission("haplogroup.update").async { implicit request => + treeRestructuringService.getSplitPreview(parentId).map { preview => + Ok(views.html.curator.haplogroups.splitBranchForm(preview.parent, preview.variantGroups, preview.children, splitBranchForm)) + }.recover { + case e: IllegalArgumentException => + NotFound(e.getMessage) + } + } + + def splitBranch(parentId: Int): Action[AnyContent] = + withPermission("haplogroup.update").async { implicit request => + treeRestructuringService.getSplitPreview(parentId).flatMap { preview => + splitBranchForm.bindFromRequest().fold( + formWithErrors => { + Future.successful(BadRequest(views.html.curator.haplogroups.splitBranchForm( + preview.parent, preview.variantGroups, preview.children, formWithErrors + ))) + }, + data => { + val newHaplogroup = Haplogroup( + id = None, + name = data.name, + lineage = data.lineage, + description = data.description, + haplogroupType = preview.parent.haplogroupType, + revisionId = 1, + source = data.source, + confidenceLevel = data.confidenceLevel, + validFrom = LocalDateTime.now(), + validUntil = None + ) + + treeRestructuringService.splitBranch( + parentId, + newHaplogroup, + data.variantGroupKeys, + data.childIds, + request.user.id.get + ).map { newId => + Redirect(routes.CuratorController.listHaplogroups(None, None, 1, 20)) + .flashing("success" -> s"Created subclade '${data.name}' under '${preview.parent.name}'") + }.recover { + case e: IllegalArgumentException => + BadRequest(views.html.curator.haplogroups.splitBranchForm( + preview.parent, preview.variantGroups, preview.children, + splitBranchForm.fill(data).withGlobalError(e.getMessage) + )) + } + } + ) + } + } + + def mergeConfirmForm(childId: Int): Action[AnyContent] = + withPermission("haplogroup.update").async { implicit request => + treeRestructuringService.getMergePreview(childId).map { preview => + Ok(views.html.curator.haplogroups.mergeConfirmForm(preview)) + }.recover { + case e: IllegalArgumentException => + NotFound(e.getMessage) + } + } + + def mergeIntoParent(childId: Int): Action[AnyContent] = + withPermission("haplogroup.update").async { implicit request => + treeRestructuringService.mergeIntoParent(childId, request.user.id.get).map { parentId => + Redirect(routes.CuratorController.haplogroupDetailPanel(parentId)) + .withHeaders("HX-Trigger" -> "haplogroupMerged") + }.recover { + case e: IllegalArgumentException => + BadRequest(e.getMessage) + } + } +} diff --git a/app/controllers/GenomicsAdminController.scala b/app/controllers/GenomicsAdminController.scala new file mode 100644 index 0000000..8f374d4 --- /dev/null +++ b/app/controllers/GenomicsAdminController.scala @@ -0,0 +1,77 @@ +package controllers + +import actors.YBrowseVariantUpdateActor +import actors.YBrowseVariantUpdateActor.{RunUpdate, UpdateResult} +import jakarta.inject.{Inject, Named, Singleton} +import org.apache.pekko.actor.ActorRef +import org.apache.pekko.pattern.ask +import org.apache.pekko.util.Timeout +import play.api.Logging +import play.api.libs.json.{Json, OWrites} +import play.api.mvc.{Action, AnyContent, BaseController, ControllerComponents} +import services.AuthService + +import java.util.UUID +import scala.concurrent.duration.* +import scala.concurrent.{ExecutionContext, Future} + +@Singleton +class GenomicsAdminController @Inject()( + val controllerComponents: ControllerComponents, + authService: AuthService, + @Named("ybrowse-variant-update-actor") ybrowseUpdateActor: ActorRef +)(implicit ec: ExecutionContext) extends BaseController with Logging { + + implicit val timeout: Timeout = Timeout(10.minutes) + + implicit val updateResultWrites: OWrites[UpdateResult] = Json.writes[UpdateResult] + + /** + * Check if current user has Admin role. + */ + private def withAdminAuth[A](request: play.api.mvc.Request[A])( + block: UUID => Future[play.api.mvc.Result] + ): Future[play.api.mvc.Result] = { + implicit val req: play.api.mvc.RequestHeader = request + request.session.get("userId").map(UUID.fromString) match { + case Some(userId) => + authService.hasRole(userId, "Admin").flatMap { + case true => block(userId) + case false => + Future.successful( + Forbidden(Json.obj("error" -> "You do not have permission to access this endpoint.")) + ) + } + case None => + Future.successful( + Unauthorized(Json.obj("error" -> "Authentication required.")) + ) + } + } + + /** + * Trigger on-demand YBrowse variant update. + * Only accessible by users with Admin role. + */ + def triggerYBrowseUpdate(): Action[AnyContent] = Action.async { implicit request => + withAdminAuth(request) { adminUserId => + logger.info(s"Admin $adminUserId triggered YBrowse variant update") + + (ybrowseUpdateActor ? RunUpdate).mapTo[UpdateResult].map { result => + if (result.success) { + Ok(Json.toJson(result)) + } else { + InternalServerError(Json.toJson(result)) + } + }.recover { + case ex: Exception => + logger.error(s"YBrowse update request failed: ${ex.getMessage}", ex) + InternalServerError(Json.obj( + "success" -> false, + "variantsIngested" -> 0, + "message" -> s"Request failed: ${ex.getMessage}" + )) + } + } + } +} diff --git a/app/controllers/TreeController.scala b/app/controllers/TreeController.scala index 9a90ea3..e175c83 100644 --- a/app/controllers/TreeController.scala +++ b/app/controllers/TreeController.scala @@ -1,5 +1,6 @@ package controllers +import config.FeatureFlags import models.HaplogroupType import models.HaplogroupType.{MT, Y} import models.api.{SubcladeDTO, TreeNodeDTO} @@ -28,6 +29,7 @@ import scala.concurrent.{ExecutionContext, Future} @Singleton class TreeController @Inject()(val controllerComponents: MessagesControllerComponents, treeService: HaplogroupTreeService, + featureFlags: FeatureFlags, cached: Cached, cache: AsyncCacheApi) (using webJarsUtil: WebJarsUtil, ec: ExecutionContext) @@ -205,7 +207,7 @@ class TreeController @Inject()(val controllerComponents: MessagesControllerCompo val treeViewModel: Option[TreeViewModel] = treeDto.subclade.flatMap { _ => services.TreeLayoutService.layoutTree(treeDto, isAbsoluteTopRootView) } - Ok(views.html.fragments.haplogroup(treeDto, config.haplogroupType, treeViewModel, request.uri)) + Ok(views.html.fragments.haplogroup(treeDto, config.haplogroupType, treeViewModel, request.uri, featureFlags.showBranchAgeEstimates)) } .recover { case _: IllegalArgumentException => @@ -252,7 +254,7 @@ class TreeController @Inject()(val controllerComponents: MessagesControllerCompo services.TreeLayoutService.layoutTree(treeDto, isAbsoluteTopRootView) } - Ok(views.html.fragments.haplogroup(treeDto, config.haplogroupType, treeViewModel, request.uri)) + Ok(views.html.fragments.haplogroup(treeDto, config.haplogroupType, treeViewModel, request.uri, featureFlags.showBranchAgeEstimates)) } } .recover { diff --git a/app/controllers/VariantController.scala b/app/controllers/VariantController.scala new file mode 100644 index 0000000..cc03b4e --- /dev/null +++ b/app/controllers/VariantController.scala @@ -0,0 +1,30 @@ +package controllers + +import jakarta.inject.{Inject, Singleton} +import models.dal.domain.genomics.Variant +import play.api.libs.json.{Json, OFormat} +import play.api.mvc.{Action, AnyContent, BaseController, ControllerComponents} +import repositories.VariantRepository + +import scala.concurrent.ExecutionContext + +@Singleton +class VariantController @Inject()( + val controllerComponents: ControllerComponents, + variantRepository: VariantRepository + )(implicit ec: ExecutionContext) extends BaseController { + + implicit val variantFormat: OFormat[Variant] = Json.format[Variant] + + /** + * Searches for variants by name (rsId or commonName). + * + * @param name The name to search for (e.g., "rs123" or "M269"). + * @return A JSON array of matching variants. + */ + def search(name: String): Action[AnyContent] = Action.async { implicit request => + variantRepository.searchByName(name).map { variants => + Ok(Json.toJson(variants)) + } + } +} diff --git a/app/models/api/TreeDTO.scala b/app/models/api/TreeDTO.scala index d347577..b69db2f 100644 --- a/app/models/api/TreeDTO.scala +++ b/app/models/api/TreeDTO.scala @@ -35,7 +35,16 @@ case class CrumbDTO(label: String, url: String) * @param updated The timestamp at which the node or its content was last updated. * @param isBackbone A boolean flag indicating whether this node is part of the backbone structure of the tree. Defaults to `false`. */ -case class TreeNodeDTO(name: String, variants: Seq[VariantDTO], children: List[TreeNodeDTO], updated: ZonedDateTime, isBackbone: Boolean = false, variantCount: Option[Int] = None) { +case class TreeNodeDTO( + name: String, + variants: Seq[VariantDTO], + children: List[TreeNodeDTO], + updated: ZonedDateTime, + isBackbone: Boolean = false, + variantCount: Option[Int] = None, + formedYbp: Option[Int] = None, + tmrcaYbp: Option[Int] = None + ) { /** * Calculates the weight of the current tree node. * @@ -77,16 +86,20 @@ case class GenomicCoordinate(start: Int, stop: Int, anc: String, der: String) { /** * Represents a genomic variant along with its name, coordinates, and variant type. * - * @param name The name of the variant. - * @param coordinates A mapping of chromosomes or regions to their respective genomic coordinates. + * @param name The name of the variant (primary display name). + * @param coordinates A mapping of reference genomes to their respective genomic coordinates. * Each `GenomicCoordinate` represents the specific start and stop positions * along with the ancestral and derived alleles for the region. * @param variantType The type of the variant, indicating the nature or classification of the mutation. + * @param aliases Alternative names for this variant, grouped by source/type. + * Keys are alias types (e.g., "common_name", "rs_id", "isogg", "yfull"). + * Values are lists of alias values from that source. */ case class VariantDTO( name: String, coordinates: Map[String, GenomicCoordinate], - variantType: String + variantType: String, + aliases: Map[String, Seq[String]] = Map.empty ) /** diff --git a/app/models/dal/DatabaseSchema.scala b/app/models/dal/DatabaseSchema.scala index f7d7fa0..f38faef 100644 --- a/app/models/dal/DatabaseSchema.scala +++ b/app/models/dal/DatabaseSchema.scala @@ -78,6 +78,7 @@ object DatabaseSchema { val specimenDonors = TableQuery[SpecimenDonorsTable] val validationServices = TableQuery[ValidationServicesTable] val variants = TableQuery[VariantsTable] + val variantAliases = TableQuery[VariantAliasTable] val testTypeDefinition = TableQuery[TestTypeTable] // New tables for Atmosphere Lexicon sync @@ -148,4 +149,9 @@ object DatabaseSchema { val contactMessages = TableQuery[ContactMessagesTable] val messageReplies = TableQuery[MessageRepliesTable] } + + object curator { + import models.dal.curator.* + val auditLog = TableQuery[AuditLogTable] + } } \ No newline at end of file diff --git a/app/models/dal/curator/AuditLogTable.scala b/app/models/dal/curator/AuditLogTable.scala new file mode 100644 index 0000000..5eb0ce0 --- /dev/null +++ b/app/models/dal/curator/AuditLogTable.scala @@ -0,0 +1,37 @@ +package models.dal.curator + +import models.dal.MyPostgresProfile.api.* +import models.domain.curator.AuditLogEntry +import play.api.libs.json.JsValue +import slick.lifted.ProvenShape + +import java.time.LocalDateTime +import java.util.UUID + +/** + * DAL table for curator.audit_log + */ +class AuditLogTable(tag: Tag) extends Table[AuditLogEntry](tag, Some("curator"), "audit_log") { + + def id = column[UUID]("id", O.PrimaryKey) + def userId = column[UUID]("user_id") + def entityType = column[String]("entity_type") + def entityId = column[Int]("entity_id") + def action = column[String]("action") + def oldValue = column[Option[JsValue]]("old_value") + def newValue = column[Option[JsValue]]("new_value") + def comment = column[Option[String]]("comment") + def createdAt = column[LocalDateTime]("created_at") + + def * : ProvenShape[AuditLogEntry] = ( + id.?, + userId, + entityType, + entityId, + action, + oldValue, + newValue, + comment, + createdAt + ).mapTo[AuditLogEntry] +} diff --git a/app/models/dal/domain/genomics/VariantAlias.scala b/app/models/dal/domain/genomics/VariantAlias.scala new file mode 100644 index 0000000..0f6deec --- /dev/null +++ b/app/models/dal/domain/genomics/VariantAlias.scala @@ -0,0 +1,43 @@ +package models.dal.domain.genomics + +import java.time.LocalDateTime + +/** + * Represents an alternative name (alias) for a variant. + * + * Variants are often known by multiple names across different research groups and databases: + * - ISOGG names (e.g., M269, P312) + * - YFull names (e.g., BY12345) + * - FTDNA names + * - dbSNP rsIDs (e.g., rs9786076) + * - Publication-specific identifiers + * + * This model allows tracking all known names for a variant while maintaining + * a primary display name. + * + * @param id Unique identifier for this alias record + * @param variantId The variant this alias belongs to + * @param aliasType Type of alias: 'common_name', 'rs_id', 'isogg', 'yfull', 'ftdna', etc. + * @param aliasValue The actual alias value (e.g., "M269", "rs9786076") + * @param source Where this alias came from: 'ybrowse', 'isogg', 'curator', 'migration', etc. + * @param isPrimary Whether this is the primary alias for its type (used for display) + * @param createdAt When this alias was recorded + */ +case class VariantAlias( + id: Option[Int] = None, + variantId: Int, + aliasType: String, + aliasValue: String, + source: Option[String] = None, + isPrimary: Boolean = false, + createdAt: LocalDateTime = LocalDateTime.now() +) + +object VariantAliasType { + val CommonName = "common_name" + val RsId = "rs_id" + val Isogg = "isogg" + val YFull = "yfull" + val Ftdna = "ftdna" + val Publication = "publication" +} diff --git a/app/models/dal/domain/genomics/VariantAliasTable.scala b/app/models/dal/domain/genomics/VariantAliasTable.scala new file mode 100644 index 0000000..fa43291 --- /dev/null +++ b/app/models/dal/domain/genomics/VariantAliasTable.scala @@ -0,0 +1,33 @@ +package models.dal.domain.genomics + +import models.dal.MyPostgresProfile.api.* + +import java.time.LocalDateTime + +/** + * Represents the `variant_alias` table in the database, which stores alternative names + * for genetic variants from different sources (YBrowse, ISOGG, YFull, publications, etc.). + * + * @param tag A Slick `Tag` object used to scope and reference the table within a database schema. + */ +class VariantAliasTable(tag: Tag) extends Table[VariantAlias](tag, Some("public"), "variant_alias") { + def id = column[Int]("id", O.PrimaryKey, O.AutoInc) + + def variantId = column[Int]("variant_id") + + def aliasType = column[String]("alias_type") + + def aliasValue = column[String]("alias_value") + + def source = column[Option[String]]("source") + + def isPrimary = column[Boolean]("is_primary") + + def createdAt = column[LocalDateTime]("created_at") + + def * = (id.?, variantId, aliasType, aliasValue, source, isPrimary, createdAt).mapTo[VariantAlias] + + def variantFK = foreignKey("variant_alias_variant_fk", variantId, TableQuery[VariantsTable])(_.variantId, onDelete = ForeignKeyAction.Cascade) + + def uniqueAlias = index("variant_alias_unique", (variantId, aliasType, aliasValue), unique = true) +} diff --git a/app/models/dal/domain/haplogroups/HaplogroupsTable.scala b/app/models/dal/domain/haplogroups/HaplogroupsTable.scala index 6637d11..1a71964 100644 --- a/app/models/dal/domain/haplogroups/HaplogroupsTable.scala +++ b/app/models/dal/domain/haplogroups/HaplogroupsTable.scala @@ -56,5 +56,23 @@ class HaplogroupsTable(tag: Tag) extends Table[Haplogroup](tag, Some("tree"), "h def validUntil = column[Option[LocalDateTime]]("valid_until") - def * = (haplogroupId.?, name, lineage, description, haplogroupType, revisionId, source, confidenceLevel, validFrom, validUntil).mapTo[Haplogroup] + // Branch age estimate columns + def formedYbp = column[Option[Int]]("formed_ybp") + + def formedYbpLower = column[Option[Int]]("formed_ybp_lower") + + def formedYbpUpper = column[Option[Int]]("formed_ybp_upper") + + def tmrcaYbp = column[Option[Int]]("tmrca_ybp") + + def tmrcaYbpLower = column[Option[Int]]("tmrca_ybp_lower") + + def tmrcaYbpUpper = column[Option[Int]]("tmrca_ybp_upper") + + def ageEstimateSource = column[Option[String]]("age_estimate_source") + + def * = ( + haplogroupId.?, name, lineage, description, haplogroupType, revisionId, source, confidenceLevel, validFrom, validUntil, + formedYbp, formedYbpLower, formedYbpUpper, tmrcaYbp, tmrcaYbpLower, tmrcaYbpUpper, ageEstimateSource + ).mapTo[Haplogroup] } diff --git a/app/models/domain/curator/AuditLogEntry.scala b/app/models/domain/curator/AuditLogEntry.scala new file mode 100644 index 0000000..725f3d3 --- /dev/null +++ b/app/models/domain/curator/AuditLogEntry.scala @@ -0,0 +1,65 @@ +package models.domain.curator + +import play.api.libs.json.JsValue + +import java.time.LocalDateTime +import java.util.UUID + +/** + * Represents an audit log entry for curator actions on haplogroups and variants. + * + * @param id UUID Primary Key + * @param userId The user who performed the action + * @param entityType The type of entity: "haplogroup" or "variant" + * @param entityId The ID of the affected entity + * @param action The action performed: "create", "update", or "delete" + * @param oldValue JSON representation of the entity before the change (for updates/deletes) + * @param newValue JSON representation of the entity after the change (for creates/updates) + * @param comment Optional comment explaining the change + * @param createdAt When the action was performed + */ +case class AuditLogEntry( + id: Option[UUID] = None, + userId: UUID, + entityType: String, + entityId: Int, + action: String, + oldValue: Option[JsValue], + newValue: Option[JsValue], + comment: Option[String], + createdAt: LocalDateTime = LocalDateTime.now() +) + +/** + * Audit action types. + */ +enum AuditAction(val value: String) { + case Create extends AuditAction("create") + case Update extends AuditAction("update") + case Delete extends AuditAction("delete") +} + +object AuditAction { + def fromString(s: String): Option[AuditAction] = s.toLowerCase match { + case "create" => Some(Create) + case "update" => Some(Update) + case "delete" => Some(Delete) + case _ => None + } +} + +/** + * Entity types that can be audited. + */ +enum AuditEntityType(val value: String) { + case Haplogroup extends AuditEntityType("haplogroup") + case Variant extends AuditEntityType("variant") +} + +object AuditEntityType { + def fromString(s: String): Option[AuditEntityType] = s.toLowerCase match { + case "haplogroup" => Some(Haplogroup) + case "variant" => Some(Variant) + case _ => None + } +} diff --git a/app/models/domain/genomics/VariantGroup.scala b/app/models/domain/genomics/VariantGroup.scala new file mode 100644 index 0000000..754aeeb --- /dev/null +++ b/app/models/domain/genomics/VariantGroup.scala @@ -0,0 +1,84 @@ +package models.domain.genomics + +/** + * Groups variants that represent the same logical SNP across different reference builds. + * Variants are grouped by commonName (primary) or rsId (fallback). + * + * For example, M269 might have positions in GRCh37, GRCh38, and T2T-CHM13, + * each stored as a separate Variant row but logically the same marker. + * + * @param groupKey The key used to group these variants (commonName or rsId) + * @param variants All variants (with their contig info) that share this group key + * @param rsId The rsId if present on any variant in the group + * @param commonName The common name if present on any variant in the group + */ +case class VariantGroup( + groupKey: String, + variants: Seq[VariantWithContig], + rsId: Option[String], + commonName: Option[String] +) { + /** + * Get all variant IDs in this group + */ + def variantIds: Seq[Int] = variants.flatMap(_.variant.variantId) + + /** + * Display name for the variant group (commonName preferred, rsId fallback) + */ + def displayName: String = commonName.orElse(rsId).getOrElse(s"ID: ${variantIds.headOption.getOrElse("?")}") + + /** + * Summary of all builds available (e.g., "GRCh37, GRCh38, T2T") + */ + def buildSummary: String = variants + .map(_.shortReferenceGenome) + .distinct + .sorted + .mkString(", ") + + /** + * Number of reference builds available for this variant + */ + def buildCount: Int = variants.map(_.shortReferenceGenome).distinct.size + + /** + * Variants sorted by reference genome for consistent display + */ + def variantsSorted: Seq[VariantWithContig] = variants.sortBy { v => + v.shortReferenceGenome match { + case "GRCh37" => 1 + case "GRCh38" => 2 + case "T2T" => 3 + case other => 4 + } + } +} + +object VariantGroup { + /** + * Creates variant groups from a sequence of variants with contig info. + * Groups by commonName (primary), falling back to rsId. + * Variants without either become single-variant groups keyed by variant ID. + */ + def fromVariants(variants: Seq[VariantWithContig]): Seq[VariantGroup] = { + // Group by the key (commonName preferred, rsId fallback, variantId last resort) + val grouped = variants.groupBy { vwc => + vwc.variant.commonName + .orElse(vwc.variant.rsId) + .getOrElse(s"variant_${vwc.variant.variantId.getOrElse(0)}") + } + + grouped.map { case (key, variantsInGroup) => + val rsId = variantsInGroup.flatMap(_.variant.rsId).headOption + val commonName = variantsInGroup.flatMap(_.variant.commonName).headOption + + VariantGroup( + groupKey = key, + variants = variantsInGroup, + rsId = rsId, + commonName = commonName + ) + }.toSeq.sortBy(_.displayName) + } +} diff --git a/app/models/domain/genomics/VariantWithContig.scala b/app/models/domain/genomics/VariantWithContig.scala new file mode 100644 index 0000000..5c5a656 --- /dev/null +++ b/app/models/domain/genomics/VariantWithContig.scala @@ -0,0 +1,27 @@ +package models.domain.genomics + +import models.dal.domain.genomics.Variant + +/** + * View model that combines a Variant with its associated GenbankContig information. + * Used for display purposes in the curator interface. + * + * @param variant The variant data + * @param contig The associated genbank contig (for position context) + */ +case class VariantWithContig( + variant: Variant, + contig: GenbankContig +) { + /** + * Formats the position as "accession:position" (e.g., "chrY:11912037") + */ + def formattedPosition: String = s"${contig.commonName.getOrElse(contig.accession)}:${variant.position}" + + /** + * Gets a short reference genome label (e.g., "GRCh38" from "GRCh38.p14") + */ + def shortReferenceGenome: String = contig.referenceGenome + .map(_.split("\\.").head) + .getOrElse("Unknown") +} diff --git a/app/models/domain/haplogroups/Haplogroup.scala b/app/models/domain/haplogroups/Haplogroup.scala index 4dea469..7b0ce5a 100644 --- a/app/models/domain/haplogroups/Haplogroup.scala +++ b/app/models/domain/haplogroups/Haplogroup.scala @@ -20,6 +20,49 @@ import java.time.LocalDateTime * @param validFrom The timestamp indicating when this haplogroup record became valid or effective. * @param validUntil An optional timestamp indicating when this haplogroup record is no longer valid. */ +/** + * Represents age estimate for a haplogroup branch (formed date or TMRCA). + * Values are in years before present (YBP) with optional 95% confidence interval. + * + * @param ybp Point estimate in years before present + * @param ybpLower Lower bound of 95% confidence interval + * @param ybpUpper Upper bound of 95% confidence interval + */ +case class AgeEstimate( + ybp: Int, + ybpLower: Option[Int] = None, + ybpUpper: Option[Int] = None + ) { + /** + * Convert YBP to calendar year (AD/BC). + * Assumes present = 1950 CE (radiocarbon dating convention). + */ + def toCalendarYear: Int = 1950 - ybp + + /** + * Format as human-readable string (e.g., "2500 BC" or "500 AD"). + */ + def formatted: String = { + val year = toCalendarYear + if (year < 0) s"${-year} BC" else s"$year AD" + } + + /** + * Format with confidence interval if available. + */ + def formattedWithRange: String = { + (ybpLower, ybpUpper) match { + case (Some(lower), Some(upper)) => + val lowerYear = 1950 - upper // Note: higher YBP = older = lower calendar year + val upperYear = 1950 - lower + val lowerStr = if (lowerYear < 0) s"${-lowerYear} BC" else s"$lowerYear AD" + val upperStr = if (upperYear < 0) s"${-upperYear} BC" else s"$upperYear AD" + s"$formatted ($lowerStr – $upperStr)" + case _ => formatted + } + } +} + case class Haplogroup( id: Option[Int] = None, name: String, @@ -30,5 +73,18 @@ case class Haplogroup( source: String, confidenceLevel: String, validFrom: LocalDateTime, - validUntil: Option[LocalDateTime] - ) \ No newline at end of file + validUntil: Option[LocalDateTime], + formedYbp: Option[Int] = None, + formedYbpLower: Option[Int] = None, + formedYbpUpper: Option[Int] = None, + tmrcaYbp: Option[Int] = None, + tmrcaYbpLower: Option[Int] = None, + tmrcaYbpUpper: Option[Int] = None, + ageEstimateSource: Option[String] = None + ) { + /** Get formed date as AgeEstimate if available */ + def formedEstimate: Option[AgeEstimate] = formedYbp.map(y => AgeEstimate(y, formedYbpLower, formedYbpUpper)) + + /** Get TMRCA as AgeEstimate if available */ + def tmrcaEstimate: Option[AgeEstimate] = tmrcaYbp.map(y => AgeEstimate(y, tmrcaYbpLower, tmrcaYbpUpper)) +} \ No newline at end of file diff --git a/app/models/view/TreeViewModels.scala b/app/models/view/TreeViewModels.scala index d2156a9..7f47ad3 100644 --- a/app/models/view/TreeViewModels.scala +++ b/app/models/view/TreeViewModels.scala @@ -12,9 +12,23 @@ case class TreeNodeViewModel( children: List[TreeNodeViewModel], fillColor: String, isBackbone: Boolean, + isRecentlyUpdated: Boolean, + formedYbp: Option[Int], + tmrcaYbp: Option[Int], x: Double, // Calculated vertical position for SVG y: Double // Calculated horizontal position (depth) for SVG - ) + ) { + /** Format formed date as calendar year (AD/BC) */ + def formedFormatted: Option[String] = formedYbp.map(ybp => formatYbp(ybp)) + + /** Format TMRCA as calendar year (AD/BC) */ + def tmrcaFormatted: Option[String] = tmrcaYbp.map(ybp => formatYbp(ybp)) + + private def formatYbp(ybp: Int): String = { + val year = 1950 - ybp + if (year < 0) s"${-year} BC" else s"$year AD" + } +} /** * Represents a link between two tree nodes, with pre-calculated SVG path data, ready for the view. diff --git a/app/modules/ApplicationModule.scala b/app/modules/ApplicationModule.scala index 55971ff..147d107 100644 --- a/app/modules/ApplicationModule.scala +++ b/app/modules/ApplicationModule.scala @@ -1,6 +1,6 @@ package modules -import actors.{GenomicStudyUpdateActor, PublicationUpdateActor} +import actors.{GenomicStudyUpdateActor, PublicationUpdateActor, YBrowseVariantUpdateActor} import com.google.inject.AbstractModule import play.api.libs.concurrent.PekkoGuiceSupport @@ -9,6 +9,7 @@ class ApplicationModule extends AbstractModule with PekkoGuiceSupport { bindActor[PublicationUpdateActor]("publication-update-actor") bindActor[GenomicStudyUpdateActor]("genomic-study-update-actor") bindActor[actors.PublicationDiscoveryActor]("publication-discovery-actor") + bindActor[YBrowseVariantUpdateActor]("ybrowse-variant-update-actor") bind(classOf[Scheduler]).asEagerSingleton() } diff --git a/app/modules/BaseModule.scala b/app/modules/BaseModule.scala index e40c400..ee3ac63 100644 --- a/app/modules/BaseModule.scala +++ b/app/modules/BaseModule.scala @@ -31,6 +31,7 @@ class BaseModule extends AbstractModule { bind(classOf[GenbankContigRepository]).to(classOf[GenbankContigRepositoryImpl]) bind(classOf[VariantRepository]).to(classOf[VariantRepositoryImpl]) + bind(classOf[VariantAliasRepository]).to(classOf[VariantAliasRepositoryImpl]) bind(classOf[HaplogroupCoreRepository]).to(classOf[HaplogroupCoreRepositoryImpl]) bind(classOf[HaplogroupRelationshipRepository]).to(classOf[HaplogroupRelationshipRepositoryImpl]) bind(classOf[HaplogroupRevisionMetadataRepository]).to(classOf[HaplogroupRevisionMetadataRepositoryImpl]) diff --git a/app/modules/Scheduler.scala b/app/modules/Scheduler.scala index 2029afe..2009ad7 100644 --- a/app/modules/Scheduler.scala +++ b/app/modules/Scheduler.scala @@ -1,6 +1,7 @@ package modules import actors.PublicationUpdateActor.UpdateAllPublications +import actors.YBrowseVariantUpdateActor import jakarta.inject.{Inject, Named, Singleton} import org.apache.pekko.actor.{ActorRef, ActorSystem} import org.apache.pekko.extension.quartz.QuartzSchedulerExtension @@ -14,7 +15,8 @@ import play.api.Logging class Scheduler @Inject()( system: ActorSystem, @Named("publication-update-actor") publicationUpdateActor: ActorRef, - @Named("publication-discovery-actor") publicationDiscoveryActor: ActorRef + @Named("publication-discovery-actor") publicationDiscoveryActor: ActorRef, + @Named("ybrowse-variant-update-actor") ybrowseVariantUpdateActor: ActorRef ) extends Logging { private val quartz = QuartzSchedulerExtension(system) @@ -36,4 +38,13 @@ class Scheduler @Inject()( case e: Exception => logger.error(s"Failed to schedule 'PublicationDiscovery' job: ${e.getMessage}", e) } + + // Schedule the YBrowseVariantUpdate job + try { + quartz.schedule("YBrowseVariantUpdate", ybrowseVariantUpdateActor, YBrowseVariantUpdateActor.RunUpdate) + logger.info("Successfully scheduled 'YBrowseVariantUpdate' job.") + } catch { + case e: Exception => + logger.error(s"Failed to schedule 'YBrowseVariantUpdate' job: ${e.getMessage}", e) + } } \ No newline at end of file diff --git a/app/repositories/CuratorAuditRepository.scala b/app/repositories/CuratorAuditRepository.scala new file mode 100644 index 0000000..fdfa438 --- /dev/null +++ b/app/repositories/CuratorAuditRepository.scala @@ -0,0 +1,85 @@ +package repositories + +import jakarta.inject.{Inject, Singleton} +import models.dal.DatabaseSchema +import models.dal.MyPostgresProfile.api.* +import models.domain.curator.AuditLogEntry +import play.api.db.slick.{DatabaseConfigProvider, HasDatabaseConfigProvider} +import slick.jdbc.JdbcProfile + +import java.util.UUID +import scala.concurrent.{ExecutionContext, Future} + +@Singleton +class CuratorAuditRepository @Inject()( + protected val dbConfigProvider: DatabaseConfigProvider +)(implicit ec: ExecutionContext) extends HasDatabaseConfigProvider[JdbcProfile] { + + private val auditLog = DatabaseSchema.curator.auditLog + + /** + * Log an audit entry. + */ + def logAction(entry: AuditLogEntry): Future[AuditLogEntry] = { + val entryWithId = entry.copy(id = Some(entry.id.getOrElse(UUID.randomUUID()))) + db.run((auditLog returning auditLog) += entryWithId) + } + + /** + * Get audit history for a specific entity. + */ + def getEntityHistory(entityType: String, entityId: Int): Future[Seq[AuditLogEntry]] = { + db.run( + auditLog + .filter(e => e.entityType === entityType && e.entityId === entityId) + .sortBy(_.createdAt.desc) + .result + ) + } + + /** + * Get recent audit actions with pagination. + */ + def getRecentActions(limit: Int = 50, offset: Int = 0): Future[Seq[AuditLogEntry]] = { + db.run( + auditLog + .sortBy(_.createdAt.desc) + .drop(offset) + .take(limit) + .result + ) + } + + /** + * Get actions by a specific user. + */ + def getActionsByUser(userId: UUID, limit: Int = 50, offset: Int = 0): Future[Seq[AuditLogEntry]] = { + db.run( + auditLog + .filter(_.userId === userId) + .sortBy(_.createdAt.desc) + .drop(offset) + .take(limit) + .result + ) + } + + /** + * Count total audit entries for pagination. + */ + def countAll(): Future[Int] = { + db.run(auditLog.length.result) + } + + /** + * Count audit entries for a specific entity. + */ + def countByEntity(entityType: String, entityId: Int): Future[Int] = { + db.run( + auditLog + .filter(e => e.entityType === entityType && e.entityId === entityId) + .length + .result + ) + } +} diff --git a/app/repositories/GenbankContigRepository.scala b/app/repositories/GenbankContigRepository.scala index a4ca1b7..88f649c 100644 --- a/app/repositories/GenbankContigRepository.scala +++ b/app/repositories/GenbankContigRepository.scala @@ -39,6 +39,28 @@ trait GenbankContigRepository { * The sequence may be empty if no matching GenbankContigs are found. */ def getByAccessions(accessions: Seq[String]): Future[Seq[GenbankContig]] + + /** + * Retrieves a sequence of GenbankContig objects corresponding to the provided common names. + * + * @param commonNames A sequence of common names for which GenbankContigs need to be fetched. + * @return A Future containing a sequence of GenbankContig objects. + */ + def findByCommonNames(commonNames: Seq[String]): Future[Seq[GenbankContig]] + + /** + * Retrieves all GenbankContig objects. + * + * @return A Future containing a sequence of all GenbankContig objects. + */ + def getAll: Future[Seq[GenbankContig]] + + /** + * Retrieves Y-DNA and mtDNA contigs (chrY and chrM). + * + * @return A Future containing a sequence of Y and MT GenbankContig objects. + */ + def getYAndMtContigs: Future[Seq[GenbankContig]] } class GenbankContigRepositoryImpl @Inject()( @@ -63,4 +85,26 @@ class GenbankContigRepositoryImpl @Inject()( val query = genbankContigs.filter(_.accession.inSet(accessions)).result db.run(query) } + + def findByCommonNames(commonNames: Seq[String]): Future[Seq[GenbankContig]] = { + val query = genbankContigs.filter(_.commonName.inSet(commonNames)).result + db.run(query) + } + + def getAll: Future[Seq[GenbankContig]] = { + val query = genbankContigs.sortBy(c => (c.referenceGenome, c.commonName)).result + db.run(query) + } + + def getYAndMtContigs: Future[Seq[GenbankContig]] = { + // Include both "chrY"/"chrM" (GRCh38) and "Y"/"M" (GRCh37) naming conventions + val query = genbankContigs + .filter(c => + c.commonName.like("chrY%") || c.commonName.like("chrM%") || + c.commonName === "Y" || c.commonName === "M" + ) + .sortBy(c => (c.referenceGenome, c.commonName)) + .result + db.run(query) + } } \ No newline at end of file diff --git a/app/repositories/HaplogroupCoreRepository.scala b/app/repositories/HaplogroupCoreRepository.scala index 57c8766..d3dd40c 100644 --- a/app/repositories/HaplogroupCoreRepository.scala +++ b/app/repositories/HaplogroupCoreRepository.scala @@ -37,19 +37,112 @@ trait HaplogroupCoreRepository { * @return a Future containing a sequence of Haplogroup objects representing the direct children */ def getDirectChildren(haplogroupId: Int): Future[Seq[Haplogroup]] + + /** + * Gets the parent haplogroup of the specified haplogroup. + * + * @param haplogroupId the unique identifier of the haplogroup + * @return a Future containing an Option of the parent Haplogroup if one exists + */ + def getParent(haplogroupId: Int): Future[Option[Haplogroup]] + + // === Curator CRUD Methods === + + /** + * Find a haplogroup by ID (active only - not soft-deleted). + */ + def findById(id: Int): Future[Option[Haplogroup]] + + /** + * Search haplogroups by name with optional type filter (active only). + */ + def search(query: String, haplogroupType: Option[HaplogroupType], limit: Int, offset: Int): Future[Seq[Haplogroup]] + + /** + * Count haplogroups matching search criteria (active only). + */ + def count(query: Option[String], haplogroupType: Option[HaplogroupType]): Future[Int] + + /** + * Count haplogroups by type (active only). + */ + def countByType(haplogroupType: HaplogroupType): Future[Int] + + /** + * Create a new haplogroup. + */ + def create(haplogroup: Haplogroup): Future[Int] + + /** + * Update an existing haplogroup. + */ + def update(haplogroup: Haplogroup): Future[Boolean] + + /** + * Soft-delete a haplogroup by setting valid_until to now. + * Also reassigns all children to the deleted haplogroup's parent. + * + * @param id the haplogroup ID to soft-delete + * @param source the source attribution for the relationship changes + * @return true if successful, false if haplogroup not found + */ + def softDelete(id: Int, source: String): Future[Boolean] + + // === Tree Restructuring Methods === + + /** + * Update a haplogroup's parent by soft-deleting the old relationship and creating a new one. + * + * @param childId the ID of the child haplogroup to re-parent + * @param newParentId the ID of the new parent haplogroup + * @param source the source attribution for the relationship change + * @return true if successful + */ + def updateParent(childId: Int, newParentId: Int, source: String): Future[Boolean] + + /** + * Create a new haplogroup with an optional parent relationship. + * + * @param haplogroup the haplogroup to create + * @param parentId optional parent haplogroup ID + * @param source the source attribution for the relationship + * @return the ID of the newly created haplogroup + */ + def createWithParent(haplogroup: Haplogroup, parentId: Option[Int], source: String): Future[Int] + + /** + * Find root haplogroups (those with no parent) for a given type. + * + * @param haplogroupType the type of haplogroup (Y or MT) + * @return a sequence of root haplogroups for that type + */ + def findRoots(haplogroupType: HaplogroupType): Future[Seq[Haplogroup]] } class HaplogroupCoreRepositoryImpl @Inject()( dbConfigProvider: DatabaseConfigProvider )(implicit ec: ExecutionContext) extends BaseRepository(dbConfigProvider) - with HaplogroupCoreRepository { + with HaplogroupCoreRepository + with Logging { import models.dal.DatabaseSchema.domain.haplogroups.{haplogroupRelationships, haplogroups} import models.dal.MyPostgresProfile.api.* + import java.time.LocalDateTime + + /** Filter for active (non-soft-deleted) haplogroups */ + private def activeHaplogroups = haplogroups.filter(h => + h.validUntil.isEmpty || h.validUntil > LocalDateTime.now() + ) + + /** Filter for active relationships */ + private def activeRelationships = haplogroupRelationships.filter(r => + r.validUntil.isEmpty || r.validUntil > LocalDateTime.now() + ) + override def getHaplogroupByName(name: String, haplogroupType: HaplogroupType): Future[Option[Haplogroup]] = { - val query = haplogroups + val query = activeHaplogroups .filter(h => h.name === name && h.haplogroupType === haplogroupType) .result .headOption @@ -103,10 +196,221 @@ class HaplogroupCoreRepositoryImpl @Inject()( override def getDirectChildren(haplogroupId: Int): Future[Seq[Haplogroup]] = { val query = for { - rel <- haplogroupRelationships if rel.parentHaplogroupId === haplogroupId - child <- haplogroups if child.haplogroupId === rel.childHaplogroupId + rel <- activeRelationships if rel.parentHaplogroupId === haplogroupId + child <- activeHaplogroups if child.haplogroupId === rel.childHaplogroupId } yield child runQuery(query.result) } + + override def getParent(haplogroupId: Int): Future[Option[Haplogroup]] = { + val query = for { + rel <- activeRelationships if rel.childHaplogroupId === haplogroupId + parent <- activeHaplogroups if parent.haplogroupId === rel.parentHaplogroupId + } yield parent + + runQuery(query.result.headOption) + } + + // === Curator CRUD Methods Implementation === + + override def findById(id: Int): Future[Option[Haplogroup]] = { + runQuery(activeHaplogroups.filter(_.haplogroupId === id).result.headOption) + } + + override def search(query: String, haplogroupType: Option[HaplogroupType], limit: Int, offset: Int): Future[Seq[Haplogroup]] = { + val baseQuery = activeHaplogroups + .filter(h => h.name.toUpperCase.like(s"%${query.toUpperCase}%")) + + val filteredQuery = haplogroupType match { + case Some(hgType) => baseQuery.filter(_.haplogroupType === hgType) + case None => baseQuery + } + + runQuery( + filteredQuery + .sortBy(_.name) + .drop(offset) + .take(limit) + .result + ) + } + + override def count(query: Option[String], haplogroupType: Option[HaplogroupType]): Future[Int] = { + val baseQuery = query match { + case Some(q) => activeHaplogroups.filter(h => h.name.toUpperCase.like(s"%${q.toUpperCase}%")) + case None => activeHaplogroups + } + + val filteredQuery = haplogroupType match { + case Some(hgType) => baseQuery.filter(_.haplogroupType === hgType) + case None => baseQuery + } + + runQuery(filteredQuery.length.result) + } + + override def countByType(haplogroupType: HaplogroupType): Future[Int] = { + runQuery(activeHaplogroups.filter(_.haplogroupType === haplogroupType).length.result) + } + + override def create(haplogroup: Haplogroup): Future[Int] = { + runQuery( + (haplogroups returning haplogroups.map(_.haplogroupId)) += haplogroup + ) + } + + override def update(haplogroup: Haplogroup): Future[Boolean] = { + haplogroup.id match { + case Some(id) => + runQuery( + haplogroups + .filter(_.haplogroupId === id) + .map(h => (h.name, h.lineage, h.description, h.source, h.confidenceLevel)) + .update((haplogroup.name, haplogroup.lineage, haplogroup.description, haplogroup.source, haplogroup.confidenceLevel)) + ).map(_ > 0) + case None => Future.successful(false) + } + } + + override def softDelete(id: Int, source: String): Future[Boolean] = { + val now = LocalDateTime.now() + + // Step 1: Find the haplogroup's current parent relationship + val findParentAction = activeRelationships + .filter(_.childHaplogroupId === id) + .map(_.parentHaplogroupId) + .result + .headOption + + // Step 2: Find all children of this haplogroup + val findChildrenAction = activeRelationships + .filter(_.parentHaplogroupId === id) + .result + + val softDeleteAction = for { + maybeParentId <- findParentAction + childRelationships <- findChildrenAction + + // Step 3: Soft-delete the haplogroup by setting valid_until + updated <- haplogroups + .filter(_.haplogroupId === id) + .filter(h => h.validUntil.isEmpty || h.validUntil > now) + .map(_.validUntil) + .update(Some(now)) + + // Step 4: Soft-delete the haplogroup's parent relationship + _ <- haplogroupRelationships + .filter(_.childHaplogroupId === id) + .filter(r => r.validUntil.isEmpty || r.validUntil > now) + .map(_.validUntil) + .update(Some(now)) + + // Step 5: If there's a parent, reassign children to it + _ <- maybeParentId match { + case Some(parentId) => + // End current relationships for children + val endCurrentRelationships = haplogroupRelationships + .filter(r => r.parentHaplogroupId === id && (r.validUntil.isEmpty || r.validUntil > now)) + .map(_.validUntil) + .update(Some(now)) + + // Create new relationships pointing to the grandparent + import models.domain.haplogroups.HaplogroupRelationship + val newRelationships = childRelationships.map { childRel => + HaplogroupRelationship( + id = None, + childHaplogroupId = childRel.childHaplogroupId, + parentHaplogroupId = parentId, + revisionId = childRel.revisionId, + validFrom = now, + validUntil = None, + source = source + ) + } + endCurrentRelationships.andThen( + (haplogroupRelationships ++= newRelationships).map(_ => ()) + ) + + case None => + // No parent - just end the children's current relationships (they become roots) + haplogroupRelationships + .filter(r => r.parentHaplogroupId === id && (r.validUntil.isEmpty || r.validUntil > now)) + .map(_.validUntil) + .update(Some(now)) + .map(_ => ()) + } + } yield updated > 0 + + runTransactionally(softDeleteAction) + } + + // === Tree Restructuring Methods Implementation === + + override def updateParent(childId: Int, newParentId: Int, source: String): Future[Boolean] = { + import models.domain.haplogroups.HaplogroupRelationship + val now = LocalDateTime.now() + + val updateAction = for { + // Soft-delete the existing parent relationship + _ <- haplogroupRelationships + .filter(r => r.childHaplogroupId === childId && (r.validUntil.isEmpty || r.validUntil > now)) + .map(_.validUntil) + .update(Some(now)) + + // Create new relationship to new parent + _ <- haplogroupRelationships += HaplogroupRelationship( + id = None, + childHaplogroupId = childId, + parentHaplogroupId = newParentId, + revisionId = 1, + validFrom = now, + validUntil = None, + source = source + ) + } yield true + + runTransactionally(updateAction) + } + + override def createWithParent(haplogroup: Haplogroup, parentId: Option[Int], source: String): Future[Int] = { + import models.domain.haplogroups.HaplogroupRelationship + val now = LocalDateTime.now() + + val createAction = for { + // Create the haplogroup + newId <- (haplogroups returning haplogroups.map(_.haplogroupId)) += haplogroup + + // Create parent relationship if parentId provided + _ <- parentId match { + case Some(pid) => + haplogroupRelationships += HaplogroupRelationship( + id = None, + childHaplogroupId = newId, + parentHaplogroupId = pid, + revisionId = 1, + validFrom = now, + validUntil = None, + source = source + ) + case None => + DBIO.successful(()) + } + } yield newId + + runTransactionally(createAction) + } + + override def findRoots(haplogroupType: HaplogroupType): Future[Seq[Haplogroup]] = { + // Find haplogroups of the given type that have no active parent relationship + val query = activeHaplogroups + .filter(_.haplogroupType === haplogroupType) + .filterNot(h => + activeRelationships.filter(_.childHaplogroupId === h.haplogroupId).exists + ) + .sortBy(_.name) + .result + + runQuery(query) + } } diff --git a/app/repositories/VariantAliasRepository.scala b/app/repositories/VariantAliasRepository.scala new file mode 100644 index 0000000..271f97f --- /dev/null +++ b/app/repositories/VariantAliasRepository.scala @@ -0,0 +1,184 @@ +package repositories + +import jakarta.inject.Inject +import models.dal.MyPostgresProfile +import models.dal.MyPostgresProfile.api.* +import models.dal.domain.genomics.{VariantAlias, VariantAliasTable} +import play.api.db.slick.{DatabaseConfigProvider, HasDatabaseConfigProvider} + +import scala.concurrent.{ExecutionContext, Future} + +/** + * Repository for managing variant aliases. + */ +trait VariantAliasRepository { + /** + * Find all aliases for a variant. + */ + def findByVariantId(variantId: Int): Future[Seq[VariantAlias]] + + /** + * Find variants by alias value (searches across all alias types). + */ + def findVariantIdsByAlias(aliasValue: String): Future[Seq[Int]] + + /** + * Find variants by alias value and type. + */ + def findVariantIdsByAliasAndType(aliasValue: String, aliasType: String): Future[Seq[Int]] + + /** + * Add an alias to a variant. Returns true if added, false if already exists. + */ + def addAlias(alias: VariantAlias): Future[Boolean] + + /** + * Add multiple aliases in batch. Returns count of aliases added. + */ + def addAliasesBatch(aliases: Seq[VariantAlias]): Future[Int] + + /** + * Check if an alias exists for a variant. + */ + def aliasExists(variantId: Int, aliasType: String, aliasValue: String): Future[Boolean] + + /** + * Set an alias as primary for its type (unsets other primaries of same type for the variant). + */ + def setPrimary(variantId: Int, aliasType: String, aliasValue: String): Future[Boolean] + + /** + * Delete an alias. + */ + def deleteAlias(variantId: Int, aliasType: String, aliasValue: String): Future[Boolean] + + /** + * Search aliases by partial match. + */ + def searchAliases(query: String, limit: Int): Future[Seq[VariantAlias]] + + /** + * Find aliases for multiple variants in batch. + * Returns a map of variantId -> Seq[VariantAlias] + */ + def findByVariantIds(variantIds: Seq[Int]): Future[Map[Int, Seq[VariantAlias]]] +} + +class VariantAliasRepositoryImpl @Inject()( + protected val dbConfigProvider: DatabaseConfigProvider +)(implicit ec: ExecutionContext) + extends VariantAliasRepository + with HasDatabaseConfigProvider[MyPostgresProfile] { + + import models.dal.DatabaseSchema.domain.genomics.variantAliases + + override def findByVariantId(variantId: Int): Future[Seq[VariantAlias]] = { + db.run( + variantAliases + .filter(_.variantId === variantId) + .sortBy(a => (a.aliasType, a.isPrimary.desc)) + .result + ) + } + + override def findVariantIdsByAlias(aliasValue: String): Future[Seq[Int]] = { + val upperValue = aliasValue.toUpperCase + db.run( + variantAliases + .filter(_.aliasValue.toUpperCase === upperValue) + .map(_.variantId) + .distinct + .result + ) + } + + override def findVariantIdsByAliasAndType(aliasValue: String, aliasType: String): Future[Seq[Int]] = { + val upperValue = aliasValue.toUpperCase + db.run( + variantAliases + .filter(a => a.aliasValue.toUpperCase === upperValue && a.aliasType === aliasType) + .map(_.variantId) + .distinct + .result + ) + } + + override def addAlias(alias: VariantAlias): Future[Boolean] = { + val insertAction = variantAliases += alias + db.run(insertAction.asTry).map(_.isSuccess) + } + + override def addAliasesBatch(aliases: Seq[VariantAlias]): Future[Int] = { + if (aliases.isEmpty) { + Future.successful(0) + } else { + // Use insertOrUpdate to handle conflicts gracefully + val actions = aliases.map { alias => + sql""" + INSERT INTO variant_alias (variant_id, alias_type, alias_value, source, is_primary, created_at) + VALUES (${alias.variantId}, ${alias.aliasType}, ${alias.aliasValue}, ${alias.source}, ${alias.isPrimary}, NOW()) + ON CONFLICT (variant_id, alias_type, alias_value) DO NOTHING + """.asUpdate + } + db.run(DBIO.sequence(actions).transactionally).map(_.sum) + } + } + + override def aliasExists(variantId: Int, aliasType: String, aliasValue: String): Future[Boolean] = { + db.run( + variantAliases + .filter(a => a.variantId === variantId && a.aliasType === aliasType && a.aliasValue === aliasValue) + .exists + .result + ) + } + + override def setPrimary(variantId: Int, aliasType: String, aliasValue: String): Future[Boolean] = { + val action = for { + // First, unset all primaries of this type for this variant + _ <- variantAliases + .filter(a => a.variantId === variantId && a.aliasType === aliasType) + .map(_.isPrimary) + .update(false) + // Then set the specified one as primary + updated <- variantAliases + .filter(a => a.variantId === variantId && a.aliasType === aliasType && a.aliasValue === aliasValue) + .map(_.isPrimary) + .update(true) + } yield updated > 0 + + db.run(action.transactionally) + } + + override def deleteAlias(variantId: Int, aliasType: String, aliasValue: String): Future[Boolean] = { + db.run( + variantAliases + .filter(a => a.variantId === variantId && a.aliasType === aliasType && a.aliasValue === aliasValue) + .delete + ).map(_ > 0) + } + + override def searchAliases(query: String, limit: Int): Future[Seq[VariantAlias]] = { + val upperQuery = query.toUpperCase + db.run( + variantAliases + .filter(_.aliasValue.toUpperCase like s"%$upperQuery%") + .sortBy(_.aliasValue) + .take(limit) + .result + ) + } + + override def findByVariantIds(variantIds: Seq[Int]): Future[Map[Int, Seq[VariantAlias]]] = { + if (variantIds.isEmpty) { + Future.successful(Map.empty) + } else { + db.run( + variantAliases + .filter(_.variantId inSet variantIds) + .sortBy(a => (a.variantId, a.aliasType, a.isPrimary.desc)) + .result + ).map(_.groupBy(_.variantId)) + } + } +} diff --git a/app/repositories/VariantRepository.scala b/app/repositories/VariantRepository.scala index 16478f0..0986b20 100644 --- a/app/repositories/VariantRepository.scala +++ b/app/repositories/VariantRepository.scala @@ -4,6 +4,7 @@ import jakarta.inject.Inject import models.dal.MyPostgresProfile import models.dal.MyPostgresProfile.api.* import models.dal.domain.genomics.Variant +import models.domain.genomics.{GenbankContig, VariantGroup, VariantWithContig} import org.postgresql.util.PSQLException import play.api.db.slick.DatabaseConfigProvider @@ -74,6 +75,69 @@ trait VariantRepository { * or newly created variant corresponding to the input sequence order. */ def findOrCreateVariantsBatch(variants: Seq[Variant]): Future[Seq[Int]] + + /** + * Searches for variants by name (rsId or commonName). + * + * @param name The name to search for. + * @return A Future containing a sequence of matching Variants. + */ + def searchByName(name: String): Future[Seq[Variant]] + + // === Curator CRUD Methods === + + /** + * Find a variant by ID. + */ + def findById(id: Int): Future[Option[Variant]] + + /** + * Find a variant by ID with its associated contig information. + */ + def findByIdWithContig(id: Int): Future[Option[VariantWithContig]] + + /** + * Search variants by name with pagination. + */ + def search(query: String, limit: Int, offset: Int): Future[Seq[Variant]] + + /** + * Search variants by name with pagination, including contig information. + */ + def searchWithContig(query: String, limit: Int, offset: Int): Future[Seq[VariantWithContig]] + + /** + * Count variants matching search criteria. + */ + def count(query: Option[String]): Future[Int] + + /** + * Update an existing variant. + */ + def update(variant: Variant): Future[Boolean] + + /** + * Delete a variant. + */ + def delete(id: Int): Future[Boolean] + + // === Variant Grouping Methods === + + /** + * Search variants and return them grouped by commonName (primary) or rsId (fallback). + * Variants with the same group key across different reference builds are grouped together. + */ + def searchGrouped(query: String, limit: Int): Future[Seq[VariantGroup]] + + /** + * Get all variants matching a group key (commonName or rsId) with their contig information. + */ + def getVariantsByGroupKey(groupKey: String): Future[Seq[VariantWithContig]] + + /** + * Group a sequence of variants (with contig info) by their logical identity. + */ + def groupVariants(variants: Seq[VariantWithContig]): Seq[VariantGroup] } class VariantRepositoryImpl @Inject()( @@ -82,7 +146,7 @@ class VariantRepositoryImpl @Inject()( extends BaseRepository(dbConfigProvider) with VariantRepository { - import models.dal.DatabaseSchema.domain.genomics.variants + import models.dal.DatabaseSchema.domain.genomics.{genbankContigs, variants} def findVariant( contigId: Int, @@ -100,6 +164,13 @@ class VariantRepositoryImpl @Inject()( db.run(query) } + def searchByName(name: String): Future[Seq[Variant]] = { + val query = variants.filter(v => + v.rsId === name || v.commonName === name + ).result + db.run(query) + } + def createVariant(variant: Variant): Future[Int] = { val insertion = (variants returning variants.map(_.variantId)) += variant db.run(insertion) @@ -147,7 +218,22 @@ class VariantRepositoryImpl @Inject()( } def findOrCreateVariantsBatch(batch: Seq[Variant]): Future[Seq[Int]] = { - // Create a sequence of individual upsert actions + findOrCreateVariantsBatchWithAliases(batch, "ybrowse") + } + + /** + * Find or create variants in batch, recording incoming names as aliases. + * + * When a variant already exists (matched by position/alleles), incoming names + * that differ from existing names are recorded as aliases. This preserves + * alternative nomenclature from different sources (YBrowse, ISOGG, publications, etc.). + * + * @param batch Variants to upsert + * @param source Source identifier for alias tracking (e.g., "ybrowse", "isogg", "curator") + * @return Sequence of variant IDs (existing or newly created) + */ + def findOrCreateVariantsBatchWithAliases(batch: Seq[Variant], source: String): Future[Seq[Int]] = { + // Create upsert actions that return variant IDs val upsertActions = batch.map { variant => sql""" INSERT INTO variant ( @@ -164,13 +250,154 @@ class VariantRepositoryImpl @Inject()( rs_id = COALESCE(EXCLUDED.rs_id, variant.rs_id), common_name = COALESCE(EXCLUDED.common_name, variant.common_name) RETURNING variant_id - """.as[Int].head // Use .head to get a single Int instead of Vector[Int] + """.as[Int].head + } + + // Execute upserts to get variant IDs + val upsertResult = runTransactionally(DBIO.sequence(upsertActions)) + + // After getting IDs, add aliases for any incoming names + upsertResult.flatMap { variantIds => + val aliasInserts = batch.zip(variantIds).flatMap { case (variant, variantId) => + val aliases = Seq( + variant.commonName.map(name => (variantId, "common_name", name)), + variant.rsId.map(id => (variantId, "rs_id", id)) + ).flatten + + aliases.map { case (vid, aliasType, aliasValue) => + sql""" + INSERT INTO variant_alias (variant_id, alias_type, alias_value, source, is_primary, created_at) + VALUES ($vid, $aliasType, $aliasValue, $source, FALSE, NOW()) + ON CONFLICT (variant_id, alias_type, alias_value) DO NOTHING + """.asUpdate + } + } + + if (aliasInserts.isEmpty) { + Future.successful(variantIds) + } else { + db.run(DBIO.sequence(aliasInserts)).map(_ => variantIds) + } + } + } + + // === Curator CRUD Methods Implementation === + + override def findById(id: Int): Future[Option[Variant]] = { + db.run(variants.filter(_.variantId === id).result.headOption) + } + + override def findByIdWithContig(id: Int): Future[Option[VariantWithContig]] = { + val query = for { + v <- variants if v.variantId === id + c <- genbankContigs if c.genbankContigId === v.genbankContigId + } yield (v, c) + + db.run(query.result.headOption).map(_.map { case (v, c) => VariantWithContig(v, c) }) + } + + override def search(query: String, limit: Int, offset: Int): Future[Seq[Variant]] = { + val upperQuery = query.toUpperCase + val searchQuery = variants.filter(v => + v.rsId.toUpperCase.like(s"%$upperQuery%") || + v.commonName.toUpperCase.like(s"%$upperQuery%") + ) + .sortBy(v => (v.commonName, v.rsId)) + .drop(offset) + .take(limit) + .result + + db.run(searchQuery) + } + + override def searchWithContig(query: String, limit: Int, offset: Int): Future[Seq[VariantWithContig]] = { + val upperQuery = query.toUpperCase + val searchQuery = (for { + v <- variants if v.rsId.toUpperCase.like(s"%$upperQuery%") || v.commonName.toUpperCase.like(s"%$upperQuery%") + c <- genbankContigs if c.genbankContigId === v.genbankContigId + } yield (v, c)) + .sortBy { case (v, _) => (v.commonName, v.rsId) } + .drop(offset) + .take(limit) + .result + + db.run(searchQuery).map(_.map { case (v, c) => VariantWithContig(v, c) }) + } + + override def count(query: Option[String]): Future[Int] = { + val baseQuery = query match { + case Some(q) => + val upperQuery = q.toUpperCase + variants.filter(v => + v.rsId.toUpperCase.like(s"%$upperQuery%") || + v.commonName.toUpperCase.like(s"%$upperQuery%") + ) + case None => variants } + db.run(baseQuery.length.result) + } - // Combine all actions into a single DBIO action - val combinedAction = DBIO.sequence(upsertActions) + override def update(variant: Variant): Future[Boolean] = { + variant.variantId match { + case Some(id) => + db.run( + variants + .filter(_.variantId === id) + .map(v => (v.variantType, v.rsId, v.commonName)) + .update((variant.variantType, variant.rsId, variant.commonName)) + ).map(_ > 0) + case None => Future.successful(false) + } + } + + override def delete(id: Int): Future[Boolean] = { + db.run(variants.filter(_.variantId === id).delete).map(_ > 0) + } + + // === Variant Grouping Methods Implementation === + + override def searchGrouped(query: String, limit: Int): Future[Seq[VariantGroup]] = { + val upperQuery = query.toUpperCase + + // Search variants by name fields OR by aliases + // Use raw SQL for the alias join to keep it efficient + val searchQuery = sql""" + SELECT DISTINCT v.variant_id, v.genbank_contig_id, v.position, v.reference_allele, + v.alternate_allele, v.variant_type, v.rs_id, v.common_name, + c.genbank_contig_id as c_id, c.accession, c.common_name as c_common_name, + c.reference_genome, COALESCE(c.seq_length, 0) as seq_len + FROM variant v + JOIN genbank_contig c ON c.genbank_contig_id = v.genbank_contig_id + LEFT JOIN variant_alias va ON va.variant_id = v.variant_id + WHERE UPPER(v.rs_id) LIKE ${s"%$upperQuery%"} + OR UPPER(v.common_name) LIKE ${s"%$upperQuery%"} + OR UPPER(va.alias_value) LIKE ${s"%$upperQuery%"} + ORDER BY v.common_name, v.rs_id + """.as[(Int, Int, Int, String, String, String, Option[String], Option[String], + Int, String, Option[String], Option[String], Int)] + + db.run(searchQuery).map { results => + val variantsWithContig = results.map { case (vid, contigId, pos, ref, alt, vtype, rsId, commonName, + cId, accession, cCommonName, refGenome, seqLen) => + val variant = Variant(Some(vid), contigId, pos, ref, alt, vtype, rsId, commonName) + val contig = GenbankContig(Some(cId), accession, cCommonName, refGenome, seqLen) + VariantWithContig(variant, contig) + } + VariantGroup.fromVariants(variantsWithContig).take(limit) + } + } + + override def getVariantsByGroupKey(groupKey: String): Future[Seq[VariantWithContig]] = { + val searchQuery = (for { + v <- variants if v.commonName === groupKey || v.rsId === groupKey + c <- genbankContigs if c.genbankContigId === v.genbankContigId + } yield (v, c)) + .result + + db.run(searchQuery).map(_.map { case (v, c) => VariantWithContig(v, c) }) + } - // Use runTransactionally from BaseRepository - runTransactionally(combinedAction) + override def groupVariants(variants: Seq[VariantWithContig]): Seq[VariantGroup] = { + VariantGroup.fromVariants(variants) } } diff --git a/app/services/CuratorAuditService.scala b/app/services/CuratorAuditService.scala new file mode 100644 index 0000000..e288e93 --- /dev/null +++ b/app/services/CuratorAuditService.scala @@ -0,0 +1,315 @@ +package services + +import jakarta.inject.{Inject, Singleton} +import models.HaplogroupType +import models.dal.domain.genomics.Variant +import models.domain.curator.AuditLogEntry +import models.domain.haplogroups.{Haplogroup, HaplogroupVariantMetadata} +import play.api.Logging +import play.api.libs.json.* +import repositories.{CuratorAuditRepository, HaplogroupVariantMetadataRepository} + +import java.time.LocalDateTime +import java.util.UUID +import scala.concurrent.{ExecutionContext, Future} + +/** + * Service for managing curator audit logging. + * Provides methods to log create, update, and delete actions for haplogroups and variants, + * as well as retrieve audit history. + */ +@Singleton +class CuratorAuditService @Inject()( + auditRepository: CuratorAuditRepository, + haplogroupVariantMetadataRepository: HaplogroupVariantMetadataRepository +)(implicit ec: ExecutionContext) extends Logging { + + // JSON formats for domain objects + private given Format[HaplogroupType] = Format( + Reads.StringReads.map(s => HaplogroupType.fromString(s).getOrElse(HaplogroupType.Y)), + Writes.StringWrites.contramap(_.toString) + ) + + private given Format[LocalDateTime] = Format( + Reads.localDateTimeReads("yyyy-MM-dd'T'HH:mm:ss"), + Writes.temporalWrites[LocalDateTime, java.time.format.DateTimeFormatter]( + java.time.format.DateTimeFormatter.ISO_LOCAL_DATE_TIME + ) + ) + + private given Format[Haplogroup] = Json.format[Haplogroup] + private given Format[Variant] = Json.format[Variant] + + // === Haplogroup Audit Methods === + + /** + * Log haplogroup creation. + */ + def logHaplogroupCreate( + userId: UUID, + haplogroup: Haplogroup, + comment: Option[String] = None + ): Future[AuditLogEntry] = { + val entry = AuditLogEntry( + userId = userId, + entityType = "haplogroup", + entityId = haplogroup.id.getOrElse(0), + action = "create", + oldValue = None, + newValue = Some(Json.toJson(haplogroup)), + comment = comment + ) + auditRepository.logAction(entry) + } + + /** + * Log haplogroup update. + */ + def logHaplogroupUpdate( + userId: UUID, + oldHaplogroup: Haplogroup, + newHaplogroup: Haplogroup, + comment: Option[String] = None + ): Future[AuditLogEntry] = { + val entry = AuditLogEntry( + userId = userId, + entityType = "haplogroup", + entityId = oldHaplogroup.id.getOrElse(0), + action = "update", + oldValue = Some(Json.toJson(oldHaplogroup)), + newValue = Some(Json.toJson(newHaplogroup)), + comment = comment + ) + auditRepository.logAction(entry) + } + + /** + * Log haplogroup soft-delete. + */ + def logHaplogroupDelete( + userId: UUID, + haplogroup: Haplogroup, + comment: Option[String] = None + ): Future[AuditLogEntry] = { + val entry = AuditLogEntry( + userId = userId, + entityType = "haplogroup", + entityId = haplogroup.id.getOrElse(0), + action = "delete", + oldValue = Some(Json.toJson(haplogroup)), + newValue = None, + comment = comment + ) + auditRepository.logAction(entry) + } + + // === Variant Audit Methods === + + /** + * Log variant creation. + */ + def logVariantCreate( + userId: UUID, + variant: Variant, + comment: Option[String] = None + ): Future[AuditLogEntry] = { + val entry = AuditLogEntry( + userId = userId, + entityType = "variant", + entityId = variant.variantId.getOrElse(0), + action = "create", + oldValue = None, + newValue = Some(Json.toJson(variant)), + comment = comment + ) + auditRepository.logAction(entry) + } + + /** + * Log variant update. + */ + def logVariantUpdate( + userId: UUID, + oldVariant: Variant, + newVariant: Variant, + comment: Option[String] = None + ): Future[AuditLogEntry] = { + val entry = AuditLogEntry( + userId = userId, + entityType = "variant", + entityId = oldVariant.variantId.getOrElse(0), + action = "update", + oldValue = Some(Json.toJson(oldVariant)), + newValue = Some(Json.toJson(newVariant)), + comment = comment + ) + auditRepository.logAction(entry) + } + + /** + * Log variant deletion. + */ + def logVariantDelete( + userId: UUID, + variant: Variant, + comment: Option[String] = None + ): Future[AuditLogEntry] = { + val entry = AuditLogEntry( + userId = userId, + entityType = "variant", + entityId = variant.variantId.getOrElse(0), + action = "delete", + oldValue = Some(Json.toJson(variant)), + newValue = None, + comment = comment + ) + auditRepository.logAction(entry) + } + + // === History Retrieval Methods === + + /** + * Get audit history for a specific haplogroup. + */ + def getHaplogroupHistory(haplogroupId: Int): Future[Seq[AuditLogEntry]] = { + auditRepository.getEntityHistory("haplogroup", haplogroupId) + } + + /** + * Get audit history for a specific variant. + */ + def getVariantHistory(variantId: Int): Future[Seq[AuditLogEntry]] = { + auditRepository.getEntityHistory("variant", variantId) + } + + /** + * Get recent audit actions across all entities. + */ + def getRecentActions(limit: Int = 50, offset: Int = 0): Future[Seq[AuditLogEntry]] = { + auditRepository.getRecentActions(limit, offset) + } + + /** + * Get audit actions by a specific user. + */ + def getActionsByUser(userId: UUID, limit: Int = 50, offset: Int = 0): Future[Seq[AuditLogEntry]] = { + auditRepository.getActionsByUser(userId, limit, offset) + } + + // === Haplogroup-Variant Association Audit Methods === + + /** + * Log when a variant is added to a haplogroup. + */ + def logVariantAddedToHaplogroup( + author: String, + haplogroupVariantId: Int, + comment: Option[String] = None + ): Future[Int] = { + val metadata = HaplogroupVariantMetadata( + haplogroup_variant_id = haplogroupVariantId, + revision_id = 1, + author = author, + timestamp = LocalDateTime.now(), + comment = comment.getOrElse("Added via curator interface"), + change_type = "add", + previous_revision_id = None + ) + haplogroupVariantMetadataRepository.addVariantRevisionMetadata(metadata) + } + + /** + * Log when a variant is removed from a haplogroup. + */ + def logVariantRemovedFromHaplogroup( + author: String, + haplogroupVariantId: Int, + comment: Option[String] = None + ): Future[Int] = { + // Get the latest revision to link to + haplogroupVariantMetadataRepository.getVariantRevisionHistory(haplogroupVariantId).flatMap { history => + val latestRevisionId = history.headOption.map(_._2.revision_id) + val nextRevisionId = latestRevisionId.map(_ + 1).getOrElse(1) + + val metadata = HaplogroupVariantMetadata( + haplogroup_variant_id = haplogroupVariantId, + revision_id = nextRevisionId, + author = author, + timestamp = LocalDateTime.now(), + comment = comment.getOrElse("Removed via curator interface"), + change_type = "remove", + previous_revision_id = latestRevisionId + ) + haplogroupVariantMetadataRepository.addVariantRevisionMetadata(metadata) + } + } + + /** + * Get revision history for a haplogroup-variant association. + */ + def getHaplogroupVariantHistory(haplogroupVariantId: Int): Future[Seq[HaplogroupVariantMetadata]] = { + haplogroupVariantMetadataRepository.getVariantRevisionHistory(haplogroupVariantId).map(_.map(_._2)) + } + + // === Tree Restructuring Audit Methods === + + /** + * Log a branch split operation. + */ + def logBranchSplit( + userId: UUID, + parentId: Int, + newHaplogroupId: Int, + movedVariantCount: Int, + movedChildIds: Seq[Int], + comment: Option[String] = None + ): Future[AuditLogEntry] = { + val details = Json.obj( + "operation" -> "split", + "parentId" -> parentId, + "newHaplogroupId" -> newHaplogroupId, + "movedVariantCount" -> movedVariantCount, + "movedChildIds" -> movedChildIds + ) + val entry = AuditLogEntry( + userId = userId, + entityType = "haplogroup", + entityId = newHaplogroupId, + action = "split", + oldValue = None, + newValue = Some(details), + comment = comment + ) + auditRepository.logAction(entry) + } + + /** + * Log a merge into parent operation. + */ + def logMergeIntoParent( + userId: UUID, + parentId: Int, + absorbedChildId: Int, + movedVariantCount: Int, + promotedChildCount: Int, + comment: Option[String] = None + ): Future[AuditLogEntry] = { + val details = Json.obj( + "operation" -> "merge", + "parentId" -> parentId, + "absorbedChildId" -> absorbedChildId, + "movedVariantCount" -> movedVariantCount, + "promotedChildCount" -> promotedChildCount + ) + val entry = AuditLogEntry( + userId = userId, + entityType = "haplogroup", + entityId = parentId, + action = "merge", + oldValue = Some(Json.obj("absorbedChildId" -> absorbedChildId)), + newValue = Some(details), + comment = comment + ) + auditRepository.logAction(entry) + } +} diff --git a/app/services/HaplogroupTreeService.scala b/app/services/HaplogroupTreeService.scala index 29187cc..8a3d163 100644 --- a/app/services/HaplogroupTreeService.scala +++ b/app/services/HaplogroupTreeService.scala @@ -4,12 +4,12 @@ import jakarta.inject.Inject import models.HaplogroupType import models.HaplogroupType.{MT, Y} import models.api.* -import models.dal.domain.genomics.Variant +import models.dal.domain.genomics.{Variant, VariantAlias} import models.domain.genomics.GenbankContig import models.domain.haplogroups.Haplogroup import play.api.Logging import play.api.mvc.Call -import repositories.{HaplogroupCoreRepository, HaplogroupVariantRepository} +import repositories.{HaplogroupCoreRepository, HaplogroupVariantRepository, VariantAliasRepository} import java.time.ZoneId import scala.concurrent.{ExecutionContext, Future} @@ -31,7 +31,8 @@ case object FragmentRoute extends RouteType */ class HaplogroupTreeService @Inject()( coreRepository: HaplogroupCoreRepository, - variantRepository: HaplogroupVariantRepository)(implicit ec: ExecutionContext) + variantRepository: HaplogroupVariantRepository, + aliasRepository: VariantAliasRepository)(implicit ec: ExecutionContext) extends Logging { /** @@ -117,7 +118,11 @@ class HaplogroupTreeService @Inject()( for { // Get variants for this haplogroup variants <- variantRepository.getHaplogroupVariants(haplogroup.id.get) - variantDTOs = mapVariants(variants) + + // Fetch aliases for all variants in batch + variantIds = variants.flatMap(_._1.variantId) + aliasMap <- aliasRepository.findByVariantIds(variantIds) + variantDTOs = mapVariants(variants, aliasMap) // Get and process children children <- coreRepository.getDirectChildren(haplogroup.id.get) @@ -128,7 +133,9 @@ class HaplogroupTreeService @Inject()( variants = variantDTOs, children = childNodes.toList, updated = haplogroup.validFrom.atZone(ZoneId.systemDefault()), - isBackbone = haplogroup.source == "backbone" // Assuming we have this field or similar logic + isBackbone = haplogroup.source == "backbone", + formedYbp = haplogroup.formedYbp, + tmrcaYbp = haplogroup.tmrcaYbp ) } @@ -140,30 +147,56 @@ class HaplogroupTreeService @Inject()( } yield TreeNodeDTO( name = haplogroup.name, variants = Seq.empty, - variantCount = Some(variantCount), // Add this field to TreeNodeDTO + variantCount = Some(variantCount), children = childNodes.toList, updated = haplogroup.validFrom.atZone(ZoneId.systemDefault()), - isBackbone = haplogroup.source == "backbone" + isBackbone = haplogroup.source == "backbone", + formedYbp = haplogroup.formedYbp, + tmrcaYbp = haplogroup.tmrcaYbp ) } - private def mapVariants(variants: Seq[(Variant, GenbankContig)]) = { + private def mapVariants(variants: Seq[(Variant, GenbankContig)], aliasMap: Map[Int, Seq[VariantAlias]] = Map.empty) = { variants.map { case (variant, contig) => + // Convert aliases to Map[String, Seq[String]] grouped by type + val aliases = variant.variantId + .flatMap(id => aliasMap.get(id)) + .getOrElse(Seq.empty) + .groupBy(_.aliasType) + .map { case (aliasType, typeAliases) => aliasType -> typeAliases.map(_.aliasValue) } + + // Format coordinate key as "RefGenome CommonName" (e.g., "GRCh38 chrY") + val coordKey = formatCoordinateKey(contig) + VariantDTO( - name = variant.commonName.getOrElse(s"${contig.accession}:${variant.position}"), + name = variant.commonName.getOrElse(s"${contig.commonName.getOrElse(contig.accession)}:${variant.position}"), coordinates = Map( - contig.accession -> GenomicCoordinate( + coordKey -> GenomicCoordinate( variant.position, variant.position, variant.referenceAllele, variant.alternateAllele ) ), - variantType = variant.variantType + variantType = variant.variantType, + aliases = aliases ) } } + private def formatCoordinateKey(contig: GenbankContig): String = { + val refGenome = contig.referenceGenome.map(shortRefGenome).getOrElse("Unknown") + val name = contig.commonName.getOrElse(contig.accession) + s"$refGenome $name" + } + + private def shortRefGenome(ref: String): String = ref match { + case r if r.contains("GRCh37") || r.contains("hg19") => "b37" + case r if r.contains("GRCh38") || r.contains("hg38") => "b38" + case r if r.contains("T2T") || r.contains("CHM13") || r.contains("hs1") => "hs1" + case other => other + } + /** * Builds a TreeDTO representation by constructing a haplogroup tree structure * for the haplogroup(s) defined by the given genetic variant. @@ -242,7 +275,9 @@ class HaplogroupTreeService @Inject()( val sortedVariantsFuture: Future[Seq[VariantDTO]] = for { haplogroup <- coreRepository.getHaplogroupByName(haplogroupName, haplogroupType) variants <- variantRepository.getHaplogroupVariants(haplogroup.flatMap(_.id).getOrElse(0)) - } yield TreeNodeDTO.sortVariants(mapVariants(variants)) + variantIds = variants.flatMap(_._1.variantId) + aliasMap <- aliasRepository.findByVariantIds(variantIds) + } yield TreeNodeDTO.sortVariants(mapVariants(variants, aliasMap)) sortedVariantsFuture.map { sortedVariants => val grouped = sortedVariants @@ -256,8 +291,14 @@ class HaplogroupTreeService @Inject()( case (acc, currentMap) => acc ++ currentMap } + // Combine aliases from all VariantDTOs in this group + val combinedAliases: Map[String, Seq[String]] = locations + .flatMap(_.aliases.toSeq) + .groupBy(_._1) + .map { case (aliasType, pairs) => aliasType -> pairs.flatMap(_._2).distinct } + // Create a new VariantDTO for the combined result - VariantDTO(first.name, combined, first.variantType) + VariantDTO(first.name, combined, first.variantType, combinedAliases) }.toSeq TreeNodeDTO.sortVariants(grouped) diff --git a/app/services/TreeLayoutService.scala b/app/services/TreeLayoutService.scala index 3db799c..90417c8 100644 --- a/app/services/TreeLayoutService.scala +++ b/app/services/TreeLayoutService.scala @@ -14,9 +14,9 @@ object TreeLayoutService { // Configuration for layout private val NODE_WIDTH = 150.0 - private val NODE_HEIGHT = 24.0 + private val NODE_HEIGHT = 80.0 private val HORIZONTAL_SPACING = 200.0 // Distance between levels (depths) - private val VERTICAL_NODE_SPACING = 30.0 // Minimum vertical space between sibling nodes + private val VERTICAL_NODE_SPACING = 90.0 // Minimum vertical space between sibling nodes (node height + gap) private val MARGIN_TOP = 50.0 private val MARGIN_LEFT = 120.0 // Left margin for the root node @@ -44,12 +44,14 @@ object TreeLayoutService { def calculateNodePositions(nodeDTO: TreeNodeDTO, depth: Int, isCurrentDisplayRoot: Boolean): TreeNodeViewModel = { val y = depth * HORIZONTAL_SPACING + MARGIN_LEFT + val isRecentlyUpdated = nodeDTO.updated.isAfter(oneYearAgo) + val fillColor = if (nodeDTO.isBackbone) { - "#90EE90" - } else if (nodeDTO.updated.isAfter(oneYearAgo)) { - "#fff0e0" + "#d4edda" // Soft sage green (established) + } else if (isRecentlyUpdated) { + "#ffeeba" // Warm amber/tan (recently edited) } else { - "#f8f8f8" + "#f8f9fa" // Light gray (default) } val childrenToProcess = if (isCurrentDisplayRoot) { @@ -81,6 +83,9 @@ object TreeLayoutService { children = childViewModels, fillColor = fillColor, isBackbone = nodeDTO.isBackbone, + isRecentlyUpdated = isRecentlyUpdated, + formedYbp = nodeDTO.formedYbp, + tmrcaYbp = nodeDTO.tmrcaYbp, x = x, y = y ) diff --git a/app/services/TreeRestructuringService.scala b/app/services/TreeRestructuringService.scala new file mode 100644 index 0000000..722917a --- /dev/null +++ b/app/services/TreeRestructuringService.scala @@ -0,0 +1,227 @@ +package services + +import jakarta.inject.{Inject, Singleton} +import models.domain.genomics.VariantGroup +import models.domain.haplogroups.Haplogroup +import play.api.Logging +import repositories.{HaplogroupCoreRepository, HaplogroupVariantRepository, VariantRepository} + +import java.util.UUID +import scala.concurrent.{ExecutionContext, Future} + +/** + * Service for tree restructuring operations: split and merge. + */ +@Singleton +class TreeRestructuringService @Inject()( + haplogroupRepository: HaplogroupCoreRepository, + haplogroupVariantRepository: HaplogroupVariantRepository, + variantRepository: VariantRepository, + auditService: CuratorAuditService +)(implicit ec: ExecutionContext) extends Logging { + + /** + * Split: Create a new subclade by moving variants and optionally re-parenting children. + * + * @param parentId ID of the parent haplogroup + * @param newHaplogroup The new subclade haplogroup to create + * @param variantGroupKeys Keys of variant groups to MOVE from parent to new child + * @param childIds IDs of existing children to re-parent under new subclade + * @param userId User performing the operation + * @return ID of newly created haplogroup + */ + def splitBranch( + parentId: Int, + newHaplogroup: Haplogroup, + variantGroupKeys: Seq[String], + childIds: Seq[Int], + userId: UUID + ): Future[Int] = { + for { + // Verify parent exists + parentOpt <- haplogroupRepository.findById(parentId) + parent = parentOpt.getOrElse(throw new IllegalArgumentException(s"Parent haplogroup $parentId not found")) + + // Get parent's current children to validate childIds + currentChildren <- haplogroupRepository.getDirectChildren(parentId) + currentChildIds = currentChildren.flatMap(_.id).toSet + _ = if (!childIds.forall(currentChildIds.contains)) { + throw new IllegalArgumentException("Some childIds are not direct children of the parent") + } + + // Create the new subclade with parent as its parent + newId <- haplogroupRepository.createWithParent(newHaplogroup, Some(parentId), "split-operation") + + // Move variants from parent to new child + movedVariantCount <- moveVariants(parentId, newId, variantGroupKeys) + + // Re-parent selected children to the new subclade + _ <- Future.traverse(childIds) { childId => + haplogroupRepository.updateParent(childId, newId, "split-operation") + } + + // Log the operation + _ <- auditService.logBranchSplit(userId, parentId, newId, movedVariantCount, childIds, Some(s"Split ${newHaplogroup.name} from ${parent.name}")) + + } yield newId + } + + /** + * Merge: Absorb a child haplogroup into its parent (inverse of split). + * Child's variants move to parent, child's children become parent's children, child is deleted. + * + * @param childId ID of the child haplogroup to absorb + * @param userId User performing the operation + * @return ID of the parent haplogroup + */ + def mergeIntoParent(childId: Int, userId: UUID): Future[Int] = { + for { + // Verify child exists and has a parent + childOpt <- haplogroupRepository.findById(childId) + child = childOpt.getOrElse(throw new IllegalArgumentException(s"Haplogroup $childId not found")) + + parentOpt <- haplogroupRepository.getParent(childId) + parent = parentOpt.getOrElse(throw new IllegalArgumentException(s"Haplogroup $childId has no parent - cannot merge root")) + parentId = parent.id.get + + // Get child's children (grandchildren) to promote + grandchildren <- haplogroupRepository.getDirectChildren(childId) + grandchildIds = grandchildren.flatMap(_.id) + + // Get child's variants to move up + childVariants <- haplogroupVariantRepository.getHaplogroupVariants(childId) + childVariantGroups = variantRepository.groupVariants(childVariants.map { case (v, c) => + models.domain.genomics.VariantWithContig(v, c) + }) + + // Get parent's existing variants to check for duplicates + parentVariants <- haplogroupVariantRepository.getHaplogroupVariants(parentId) + parentVariantIds = parentVariants.map(_._1.variantId).flatten.toSet + + // Move unique variants from child to parent + movedVariantCount <- moveVariantsUp(childId, parentId, parentVariantIds) + + // Promote grandchildren to parent + _ <- Future.traverse(grandchildIds) { grandchildId => + haplogroupRepository.updateParent(grandchildId, parentId, "merge-operation") + } + + // Soft-delete the child (this will also soft-delete its parent relationship) + _ <- haplogroupRepository.softDelete(childId, "merge-operation") + + // Log the operation + _ <- auditService.logMergeIntoParent(userId, parentId, childId, movedVariantCount, grandchildIds.size, Some(s"Merged ${child.name} into ${parent.name}")) + + } yield parentId + } + + /** + * Move variant groups from source haplogroup to target haplogroup. + */ + private def moveVariants(sourceId: Int, targetId: Int, groupKeys: Seq[String]): Future[Int] = { + if (groupKeys.isEmpty) { + Future.successful(0) + } else { + // For each group key, get all variants and move them + Future.traverse(groupKeys) { groupKey => + for { + variants <- variantRepository.getVariantsByGroupKey(groupKey) + movedCount <- Future.traverse(variants) { vwc => + val variantId = vwc.variant.variantId.get + for { + // Remove from source + _ <- haplogroupVariantRepository.removeVariantFromHaplogroup(sourceId, variantId) + // Add to target + _ <- haplogroupVariantRepository.addVariantToHaplogroup(targetId, variantId) + } yield 1 + } + } yield movedCount.sum + }.map(_.sum) + } + } + + /** + * Move all unique variants from child to parent. + */ + private def moveVariantsUp(childId: Int, parentId: Int, existingParentVariantIds: Set[Int]): Future[Int] = { + for { + childVariants <- haplogroupVariantRepository.getVariantsByHaplogroup(childId) + childVariantIds = childVariants.flatMap(_.variantId) + + // Only move variants that don't already exist on parent + uniqueVariantIds = childVariantIds.filterNot(existingParentVariantIds.contains) + + // Move unique variants + _ <- Future.traverse(uniqueVariantIds) { variantId => + for { + _ <- haplogroupVariantRepository.removeVariantFromHaplogroup(childId, variantId) + _ <- haplogroupVariantRepository.addVariantToHaplogroup(parentId, variantId) + } yield () + } + } yield uniqueVariantIds.size + } + + /** + * Get preview information for a split operation. + */ + def getSplitPreview(parentId: Int): Future[SplitPreview] = { + for { + parentOpt <- haplogroupRepository.findById(parentId) + parent = parentOpt.getOrElse(throw new IllegalArgumentException(s"Parent haplogroup $parentId not found")) + variants <- haplogroupVariantRepository.getHaplogroupVariants(parentId) + variantGroups = variantRepository.groupVariants(variants.map { case (v, c) => + models.domain.genomics.VariantWithContig(v, c) + }) + children <- haplogroupRepository.getDirectChildren(parentId) + } yield SplitPreview(parent, variantGroups, children) + } + + /** + * Get preview information for a merge operation. + */ + def getMergePreview(childId: Int): Future[MergePreview] = { + for { + childOpt <- haplogroupRepository.findById(childId) + child = childOpt.getOrElse(throw new IllegalArgumentException(s"Haplogroup $childId not found")) + + parentOpt <- haplogroupRepository.getParent(childId) + parent = parentOpt.getOrElse(throw new IllegalArgumentException(s"Haplogroup $childId has no parent")) + + childVariants <- haplogroupVariantRepository.getHaplogroupVariants(childId) + childVariantGroups = variantRepository.groupVariants(childVariants.map { case (v, c) => + models.domain.genomics.VariantWithContig(v, c) + }) + + grandchildren <- haplogroupRepository.getDirectChildren(childId) + + parentVariants <- haplogroupVariantRepository.getHaplogroupVariants(parent.id.get) + parentVariantIds = parentVariants.map(_._1.variantId).flatten.toSet + + // Calculate unique variants that will be moved + uniqueVariantGroups = childVariantGroups.filter { group => + group.variantIds.exists(!parentVariantIds.contains(_)) + } + + } yield MergePreview(child, parent, childVariantGroups, uniqueVariantGroups, grandchildren) + } +} + +/** + * Preview data for a split operation. + */ +case class SplitPreview( + parent: Haplogroup, + variantGroups: Seq[VariantGroup], + children: Seq[Haplogroup] +) + +/** + * Preview data for a merge operation. + */ +case class MergePreview( + child: Haplogroup, + parent: Haplogroup, + allVariantGroups: Seq[VariantGroup], + uniqueVariantGroups: Seq[VariantGroup], + grandchildren: Seq[Haplogroup] +) diff --git a/app/services/genomics/YBrowseVariantIngestionService.scala b/app/services/genomics/YBrowseVariantIngestionService.scala new file mode 100644 index 0000000..0c4f905 --- /dev/null +++ b/app/services/genomics/YBrowseVariantIngestionService.scala @@ -0,0 +1,332 @@ +package services.genomics + +import config.GenomicsConfig +import htsjdk.samtools.liftover.LiftOver +import htsjdk.samtools.reference.{ReferenceSequenceFile, ReferenceSequenceFileFactory} +import htsjdk.samtools.util.Interval +import htsjdk.variant.variantcontext.VariantContext +import htsjdk.variant.vcf.VCFFileReader +import jakarta.inject.{Inject, Singleton} +import models.dal.domain.genomics.Variant +import play.api.Logger +import repositories.{GenbankContigRepository, VariantRepository} + +import java.io.File +import scala.concurrent.{ExecutionContext, Future} +import scala.jdk.CollectionConverters.* + +@Singleton +class YBrowseVariantIngestionService @Inject()( + variantRepository: VariantRepository, + genbankContigRepository: GenbankContigRepository, + genomicsConfig: GenomicsConfig + )(implicit ec: ExecutionContext) { + + private val logger = Logger(this.getClass) + + // Lazy-load ReferenceSequenceFile for each configured reference genome + private val referenceFastaFiles: Map[String, ReferenceSequenceFile] = genomicsConfig.fastaPaths.flatMap { + case (genome, fastaFile) => + if (fastaFile.exists()) { + logger.info(s"Loading reference FASTA for $genome from ${fastaFile.getPath}") + Some(genome -> ReferenceSequenceFileFactory.getReferenceSequenceFile(fastaFile)) + } else { + logger.warn(s"Reference FASTA file for $genome not found at ${fastaFile.getPath}. Normalization might be incomplete.") + None + } + } + + /** + * Ingests variants from a YBrowse VCF file. + * + * @param vcfFile The VCF file to ingest. + * @param sourceGenome The reference genome of the input VCF (default: "GRCh38"). + * @return A Future containing the number of variants ingested. + */ + def ingestVcf(vcfFile: File, sourceGenome: String = "GRCh38"): Future[Int] = { + val reader = new VCFFileReader(vcfFile, false) + val iterator = reader.iterator().asScala + + // Resolve canonical source genome name + val canonicalSource = genomicsConfig.resolveReferenceName(sourceGenome) + + // Identify target genomes (all supported except source) + val targetGenomes = genomicsConfig.supportedReferences.filter(_ != canonicalSource) + + // Load available liftover chains + val liftovers: Map[String, LiftOver] = targetGenomes.flatMap { target => + genomicsConfig.getLiftoverChainFile(canonicalSource, target) match { + case Some(file) if file.exists() => + logger.info(s"Loaded liftover chain for $canonicalSource -> $target: ${file.getPath}") + Some(target -> new LiftOver(file)) + case Some(file) => + logger.warn(s"Liftover chain file defined for $canonicalSource -> $target but not found at: ${file.getPath}") + None + case None => + logger.debug(s"No liftover chain defined for $canonicalSource -> $target") + None + } + }.toMap + + val batchSize = 1000 + + processBatches(iterator, batchSize, liftovers, canonicalSource) + } + + private def processBatches( + iterator: Iterator[VariantContext], + batchSize: Int, + liftovers: Map[String, LiftOver], + sourceGenome: String + ): Future[Int] = { + + def processNextBatch(accumulatedCount: Int): Future[Int] = { + if (!iterator.hasNext) { + Future.successful(accumulatedCount) + } else { + val batch = iterator.take(batchSize).toSeq + processBatch(batch, liftovers, sourceGenome).flatMap { count => + processNextBatch(accumulatedCount + count) + } + } + } + + processNextBatch(0) + } + + private def processBatch(batch: Seq[VariantContext], liftovers: Map[String, LiftOver], sourceGenome: String): Future[Int] = { + // 1. Collect all contig names from batch + val contigNames = batch.map(_.getContig).distinct + // 2. Resolve Contig IDs for hg38 (source) and targets + + genbankContigRepository.findByCommonNames(contigNames).flatMap { contigs => + // Map: (CommonName, Genome) -> ContigID + val contigMap = contigs.flatMap { c => + for { + cn <- c.commonName + rg <- c.referenceGenome + } yield (cn, rg) -> c.id.get + }.toMap + + val variantsToSave = batch.flatMap { vc => + // Create source variants (normalization happens in createVariantsForContext) + val sourceVariants = createVariantsForContext(vc, sourceGenome, contigMap) + + // Create lifted variants + val liftedVariants = liftovers.flatMap { case (targetGenome, liftOver) => + val interval = new Interval(vc.getContig, vc.getStart, vc.getEnd) + val lifted = liftOver.liftOver(interval) + if (lifted != null) { + // Create variant context with new position + val liftedVc = new htsjdk.variant.variantcontext.VariantContextBuilder(vc) + .chr(lifted.getContig) + .start(lifted.getStart) + .stop(lifted.getEnd) + .make() + + createVariantsForContext(liftedVc, targetGenome, contigMap) + } else { + Seq.empty + } + } + + sourceVariants ++ liftedVariants + } + + variantRepository.findOrCreateVariantsBatch(variantsToSave).map(_.size) + } + } + + private def createVariantsForContext( + vc: VariantContext, + genome: String, + contigMap: Map[(String, String), Int] + ): Seq[Variant] = { + // Resolve contig ID + // Try exact match or fallbacks (e.g. remove "chr") + val contigIdOpt = contigMap.get((vc.getContig, genome)) + .orElse(contigMap.get((vc.getContig.stripPrefix("chr"), genome))) + + contigIdOpt match { + case Some(contigId) => + vc.getAlternateAlleles.asScala.map { alt => + val rawId = Option(vc.getID).filterNot(id => id == "." || id.isEmpty) + val rsId = rawId.filter(_.toLowerCase.startsWith("rs")) + // For Y-DNA, the ID column often contains the SNP name (e.g. M269), which is the common name. + val commonName = rawId + + // Normalize the variant (left-align INDELs) + val refSeq = referenceFastaFiles.get(genome) + val (normPos, normRef, normAlt) = normalizeVariant( + vc.getContig, + vc.getStart, + vc.getReference.getDisplayString, + alt.getDisplayString, + refSeq + ) + + Variant( + genbankContigId = contigId, + position = normPos, + referenceAllele = normRef, + alternateAllele = normAlt, + variantType = vc.getType.toString, + rsId = rsId, + commonName = commonName + ) + }.toSeq + case None => + // Logger.warn(s"Contig not found for ${vc.getContig} in $genome") + Seq.empty + } + } + + /** + * Normalizes a variant by performing VCF-style left-alignment. + * + * The algorithm: + * 1. Right-trim: Remove common suffix bases from ref and alt alleles + * 2. Pad: If either allele becomes empty, prepend the preceding reference base + * 3. Left-trim: Remove common prefix bases (keeping at least 1 base on each) + * + * @param contig The contig name for reference lookup + * @param pos The 1-based position + * @param ref The reference allele + * @param alt The alternate allele + * @param refSeq Optional reference sequence file for padding lookup + * @return A tuple of (normalizedPos, normalizedRef, normalizedAlt) + */ + private def normalizeVariant( + contig: String, + pos: Int, + ref: String, + alt: String, + refSeq: Option[ReferenceSequenceFile] + ): (Int, String, String) = { + // Expand compressed repeat notation (e.g., "3T" -> "TTT") + val expandedRef = expandRepeatNotation(ref) + val expandedAlt = expandRepeatNotation(alt) + + // Skip normalization for SNPs (single base, same length) + if (expandedRef.length == 1 && expandedAlt.length == 1) { + return (pos, expandedRef, expandedAlt) + } + + var currRef = expandedRef + var currAlt = expandedAlt + var currPos = pos + + // Step 1: Right-trim common suffix bases + while (currRef.nonEmpty && currAlt.nonEmpty && currRef.last == currAlt.last) { + currRef = currRef.dropRight(1) + currAlt = currAlt.dropRight(1) + } + + // Step 2: Pad with preceding base if either allele is empty + if (currRef.isEmpty || currAlt.isEmpty) { + currPos -= 1 + val paddingBase = refSeq match { + case Some(rs) => + try { + new String(rs.getSubsequenceAt(contig, currPos, currPos).getBases, "UTF-8") + } catch { + case _: Exception => "N" + } + case None => "N" + } + currRef = paddingBase + currRef + currAlt = paddingBase + currAlt + } + + // Step 3: Left-trim common prefix bases (keeping at least 1 base) + while (currRef.length > 1 && currAlt.length > 1 && currRef.head == currAlt.head) { + currRef = currRef.tail + currAlt = currAlt.tail + currPos += 1 + } + + (currPos, currRef, currAlt) + } + + /** + * Expands compressed repeat notation (e.g., "3T" -> "TTT", "2AG" -> "AGAG"). + * Returns the input unchanged if it's already a valid nucleotide sequence. + */ + private def expandRepeatNotation(allele: String): String = { + if (allele.forall(c => "ACGTN".contains(c.toUpper))) { + allele + } else { + val (digits, bases) = allele.partition(_.isDigit) + if (digits.nonEmpty && bases.nonEmpty) { + bases * digits.toInt + } else { + bases + } + } + } + + /** + * Lifts a variant to all other supported reference genomes. + * + * @param sourceVariant The variant to lift (must have genbankContigId resolved) + * @param sourceContig The source contig information + * @return Future containing lifted variants for each target genome (may be empty if liftover fails) + */ + def liftoverVariant(sourceVariant: Variant, sourceContig: models.domain.genomics.GenbankContig): Future[Seq[Variant]] = { + val sourceGenome = sourceContig.referenceGenome.getOrElse("GRCh38") + val canonicalSource = genomicsConfig.resolveReferenceName(sourceGenome) + val sourceContigName = sourceContig.commonName.getOrElse(sourceContig.accession) + + // Get target genomes (all supported except source) + val targetGenomes = genomicsConfig.supportedReferences.filter(_ != canonicalSource) + + // Load liftover chains for each target + val liftoverResults = targetGenomes.flatMap { targetGenome => + genomicsConfig.getLiftoverChainFile(canonicalSource, targetGenome) match { + case Some(chainFile) if chainFile.exists() => + val liftOver = new LiftOver(chainFile) + val interval = new Interval(sourceContigName, sourceVariant.position, sourceVariant.position) + val lifted = liftOver.liftOver(interval) + + if (lifted != null) { + logger.info(s"Lifted ${sourceVariant.commonName.getOrElse("variant")} from $canonicalSource:$sourceContigName:${sourceVariant.position} to $targetGenome:${lifted.getContig}:${lifted.getStart}") + Some((targetGenome, lifted.getContig, lifted.getStart)) + } else { + logger.warn(s"Failed to liftover ${sourceVariant.commonName.getOrElse("variant")} from $canonicalSource to $targetGenome") + None + } + case _ => + logger.debug(s"No liftover chain available for $canonicalSource -> $targetGenome") + None + } + } + + // Resolve contig IDs for lifted positions + val targetContigNames = liftoverResults.map(_._2).distinct + + genbankContigRepository.findByCommonNames(targetContigNames).map { contigs => + // Map: (CommonName, Genome) -> ContigID + val contigMap = contigs.flatMap { c => + for { + cn <- c.commonName + rg <- c.referenceGenome + } yield (cn, rg) -> c.id.get + }.toMap + + liftoverResults.flatMap { case (targetGenome, liftedContig, liftedPos) => + // Try to find contig ID, handling chr prefix differences + val contigId = contigMap.get((liftedContig, targetGenome)) + .orElse(contigMap.get((liftedContig.stripPrefix("chr"), targetGenome))) + .orElse(contigMap.get(("chr" + liftedContig, targetGenome))) + + contigId.map { cid => + sourceVariant.copy( + variantId = None, + genbankContigId = cid, + position = liftedPos + ) + } + } + } + } +} diff --git a/app/views/curator/audit/historyPanel.scala.html b/app/views/curator/audit/historyPanel.scala.html new file mode 100644 index 0000000..dcbeded --- /dev/null +++ b/app/views/curator/audit/historyPanel.scala.html @@ -0,0 +1,87 @@ +@import models.domain.curator.AuditLogEntry +@import play.api.libs.json.Json +@(entityType: String, entityId: Int, history: Seq[AuditLogEntry])(implicit request: RequestHeader) + +
+
+
Audit History
+ @entityType #@entityId +
+
+ @if(history.isEmpty) { +

No audit history available.

+ } else { +
+ @for(entry <- history) { +
+
+ @entry.action + @entry.createdAt.toLocalDate @entry.createdAt.toLocalTime.toString.take(5) +
+ + @entry.comment.map { c => +

@c

+ } + + @if(entry.action == "update" && entry.oldValue.isDefined && entry.newValue.isDefined) { +
+ View changes +
+
+ Before: +
@Json.prettyPrint(entry.oldValue.get)
+
+
+ After: +
@Json.prettyPrint(entry.newValue.get)
+
+
+
+ } + + @if(entry.action == "create" && entry.newValue.isDefined) { +
+ View created data +
@Json.prettyPrint(entry.newValue.get)
+
+ } + + @if(entry.action == "delete" && entry.oldValue.isDefined) { +
+ View deleted data +
@Json.prettyPrint(entry.oldValue.get)
+
+ } +
+ } +
+ } + +
+ @if(entityType == "haplogroup") { + + Back to Details + + } else { + + Back to Details + + } +
+
+
+ +@actionBadgeClass(action: String) = @{ + action match { + case "create" => "bg-success" + case "update" => "bg-warning text-dark" + case "delete" => "bg-danger" + case _ => "bg-secondary" + } +} diff --git a/app/views/curator/dashboard.scala.html b/app/views/curator/dashboard.scala.html new file mode 100644 index 0000000..2c34a95 --- /dev/null +++ b/app/views/curator/dashboard.scala.html @@ -0,0 +1,77 @@ +@import org.webjars.play.WebJarsUtil +@(yHaplogroupCount: Int, mtHaplogroupCount: Int, variantCount: Int)(implicit request: RequestHeader, messages: Messages, webJarsUtil: WebJarsUtil) + +@main("Curator Dashboard") { +
+

Curator Dashboard

+ + @request.flash.get("success").map { msg => + + } + +
+
+
+
+
Y-DNA Haplogroups
+

@yHaplogroupCount

+ + Manage Y-DNA + +
+
+
+
+
+
+
mtDNA Haplogroups
+

@mtHaplogroupCount

+ + Manage mtDNA + +
+
+
+
+
+
+
Variants
+

@variantCount

+ + Manage Variants + +
+
+
+
+ +
+ +
+
+} diff --git a/app/views/curator/haplogroups/createForm.scala.html b/app/views/curator/haplogroups/createForm.scala.html new file mode 100644 index 0000000..55bb145 --- /dev/null +++ b/app/views/curator/haplogroups/createForm.scala.html @@ -0,0 +1,378 @@ +@import org.webjars.play.WebJarsUtil +@import controllers.CreateHaplogroupFormData +@import models.domain.haplogroups.Haplogroup +@(form: Form[CreateHaplogroupFormData], yRoots: Seq[Haplogroup], mtRoots: Seq[Haplogroup])(implicit request: RequestHeader, messages: Messages, webJarsUtil: WebJarsUtil) + +@main("Create Haplogroup") { +
+
+
+ + +
+
+
Create Haplogroup
+
+
+ @form.globalError.map { error => +
@error.message
+ } + +
+ Creation Rules: +
    +
  • New Root: Creates the tree root (only if no root exists)
  • +
  • New Root Above Existing: Creates a new root and makes the current root its child (e.g., Neanderthal above Human)
  • +
  • New Leaf: Creates a terminal node under an existing haplogroup
  • +
  • To create an internal node (intermediate haplogroup), use the Split function from an existing haplogroup
  • +
+
+ + @helper.form(controllers.routes.CuratorController.createHaplogroup) { + @helper.CSRF.formField + +
+ + + @form.error("haplogroupType").map { error => +
@error.message
+ } +
+ +
+ + +
+ + +
+ + + +
+ + +
+ + + +
+ + + +
+ +
+ + + @form.error("name").map { error => +
@error.message
+ } +
+ +
+ + +
+ +
+ + +
+ +
+
+ + + @form.error("source").map { error => +
@error.message
+ } +
+
+ + + @form.error("confidenceLevel").map { error => +
@error.message
+ } +
+
+ +
+ + + Cancel + +
+ } +
+
+
+
+
+ + +} diff --git a/app/views/curator/haplogroups/detailPanel.scala.html b/app/views/curator/haplogroups/detailPanel.scala.html new file mode 100644 index 0000000..3c0d0e0 --- /dev/null +++ b/app/views/curator/haplogroups/detailPanel.scala.html @@ -0,0 +1,148 @@ +@import models.domain.haplogroups.Haplogroup +@import models.domain.genomics.VariantGroup +@import models.domain.curator.AuditLogEntry +@(haplogroup: Haplogroup, parentOpt: Option[Haplogroup], children: Seq[Haplogroup], variantGroups: Seq[VariantGroup], history: Seq[AuditLogEntry])(implicit request: RequestHeader) + +
+
+
@haplogroup.name
+ + @haplogroup.haplogroupType + +
+
+
+
Lineage
+
@haplogroup.lineage.getOrElse("-")
+ +
Description
+
@haplogroup.description.getOrElse("-")
+ +
Source
+
@haplogroup.source
+ +
Confidence
+
@haplogroup.confidenceLevel
+ + @if(haplogroup.formedYbp.isDefined || haplogroup.tmrcaYbp.isDefined) { +
Branch Ages
+
+ @haplogroup.formedEstimate.map { est => +
Formed: @est.formattedWithRange
+ } + @haplogroup.tmrcaEstimate.map { est => +
TMRCA: @est.formattedWithRange
+ } + @haplogroup.ageEstimateSource.map { src => +
Source: @src
+ } +
+ } + +
Valid From
+
@haplogroup.validFrom.toLocalDate
+ + @haplogroup.validUntil.map { until => +
Valid Until
+
@until.toLocalDate
+ } +
+ +
+ +
Tree Position
+
+
Parent
+
+ @parentOpt.map { parent => + + @parent.name + + }.getOrElse { Root } +
+ +
Children
+
+ @if(children.isEmpty) { + None + } else { + @for(child <- children.take(10)) { + + @child.name + + } + @if(children.size > 10) { + +@(children.size - 10) more + } + } +
+
+ +
+ +
+ @variantsPanel(haplogroup.id.get, variantGroups) +
+ +
+ +
+ + Edit + + + Split + + @parentOpt.map { _ => + + Merge into Parent + + } + +
+ + @if(history.nonEmpty) { +
+
Recent History
+ + @if(history.size > 5) { + + View all history... + + } + } +
+
+ +@actionBadgeClass(action: String) = @{ + action match { + case "create" => "bg-success" + case "update" => "bg-warning text-dark" + case "delete" => "bg-danger" + case _ => "bg-secondary" + } +} diff --git a/app/views/curator/haplogroups/editForm.scala.html b/app/views/curator/haplogroups/editForm.scala.html new file mode 100644 index 0000000..afc0ada --- /dev/null +++ b/app/views/curator/haplogroups/editForm.scala.html @@ -0,0 +1,191 @@ +@import org.webjars.play.WebJarsUtil +@import controllers.HaplogroupFormData +@(id: Int, form: Form[HaplogroupFormData])(implicit request: RequestHeader, messages: Messages, webJarsUtil: WebJarsUtil) + +@main("Edit Haplogroup") { +
+
+
+ +
+
+ +
+
+
+
+
Edit Haplogroup
+
+
+ @helper.form(controllers.routes.CuratorController.updateHaplogroup(id)) { + @helper.CSRF.formField + +
+
+ + + @form.error("name").map { error => +
@error.message
+ } +
+
+ + + +
+
+ +
+ + +
+ +
+ + +
+ +
+
+ + + @form.error("source").map { error => +
@error.message
+ } +
+
+ + + @form.error("confidenceLevel").map { error => +
@error.message
+ } +
+
+ +
+
Branch Age Estimates (YBP)
+ +
+
+
+
Formed
+
+
+ +
+
+ +
+
+ +
+
+
+
+
+
+
TMRCA
+
+
+ +
+
+ +
+
+ +
+
+
+
+
+ +
+ + +
+ +
+ + + Cancel + +
+ } +
+
+
+
+
+} diff --git a/app/views/curator/haplogroups/list.scala.html b/app/views/curator/haplogroups/list.scala.html new file mode 100644 index 0000000..a8b827e --- /dev/null +++ b/app/views/curator/haplogroups/list.scala.html @@ -0,0 +1,102 @@ +@import org.webjars.play.WebJarsUtil +@import models.domain.haplogroups.Haplogroup +@(haplogroups: Seq[Haplogroup], query: Option[String], hgType: Option[String], currentPage: Int, totalPages: Int, pageSize: Int)(implicit request: RequestHeader, messages: Messages, webJarsUtil: WebJarsUtil) + +@main("Curator - Haplogroups") { + + +
+
+
+ +
+
+ + @request.flash.get("success").map { msg => + + } + +
+
+
+
+
Haplogroups
+ + Create + +
+
+
+
+ +
+
+ +
+
+ +
+ @listFragment(haplogroups, query, hgType, currentPage, totalPages, pageSize) +
+
+
+
+ +
+
+
+
+ +

Select a haplogroup to view details

+
+
+
+
+
+
+ + +} diff --git a/app/views/curator/haplogroups/listFragment.scala.html b/app/views/curator/haplogroups/listFragment.scala.html new file mode 100644 index 0000000..980cb90 --- /dev/null +++ b/app/views/curator/haplogroups/listFragment.scala.html @@ -0,0 +1,78 @@ +@import models.domain.haplogroups.Haplogroup +@(haplogroups: Seq[Haplogroup], query: Option[String], hgType: Option[String], currentPage: Int, totalPages: Int, pageSize: Int)(implicit request: RequestHeader) + +@if(haplogroups.isEmpty) { +
No haplogroups found matching your criteria.
+} else { +
+ + + + + + + + + + + + @for(hg <- haplogroups) { + + + + + + + + } + +
NameTypeSourceConfidence
@hg.name + + @hg.haplogroupType + + @hg.source@hg.confidenceLevel + + + +
+
+ + @if(totalPages > 1) { + + } +} diff --git a/app/views/curator/haplogroups/mergeConfirmForm.scala.html b/app/views/curator/haplogroups/mergeConfirmForm.scala.html new file mode 100644 index 0000000..ba51708 --- /dev/null +++ b/app/views/curator/haplogroups/mergeConfirmForm.scala.html @@ -0,0 +1,126 @@ +@import org.webjars.play.WebJarsUtil +@import services.MergePreview +@(preview: MergePreview)(implicit request: RequestHeader, messages: Messages, webJarsUtil: WebJarsUtil) + +@main(s"Merge ${preview.child.name}") { +
+
+
+ + +
+
+
Confirm Merge into Parent
+
+
+
+ This operation will: +
    +
  1. Move all variants from @preview.child.name up to @preview.parent.name
  2. +
  3. Move all children of @preview.child.name to become children of @preview.parent.name
  4. +
  5. Delete @preview.child.name
  6. +
+
+ +
+
+
+
+ Will be Deleted +
+
+
@preview.child.name
+ @preview.child.haplogroupType + @preview.child.lineage.map { l => +
@l
+ } +
+
+
+
+
+
+ Will Absorb +
+
+
@preview.parent.name
+ @preview.parent.haplogroupType + @preview.parent.lineage.map { l => +
@l
+ } +
+
+
+
+ +
What will be transferred:
+ +
+ Variants: + @if(preview.allVariantGroups.nonEmpty) { + @preview.uniqueVariantGroups.size of @preview.allVariantGroups.size group(s) will be added +
    + @for(group <- preview.allVariantGroups) { +
  • + @if(preview.uniqueVariantGroups.exists(_.groupKey == group.groupKey)) { + + } else { + + (already on parent) + } + @group.displayName + @group.buildCount build@if(group.buildCount != 1){s} +
  • + } +
+ } else { + No variants + } +
+ +
+ Children to promote: + @if(preview.grandchildren.nonEmpty) { + @preview.grandchildren.size +
    + @for(child <- preview.grandchildren) { +
  • + + @child.name + @child.haplogroupType +
  • + } +
+ } else { + No children + } +
+ + @helper.form(controllers.routes.CuratorController.mergeIntoParent(preview.child.id.get)) { + @helper.CSRF.formField + +
+ + + Cancel + +
+ } +
+
+
+
+
+} diff --git a/app/views/curator/haplogroups/splitBranchForm.scala.html b/app/views/curator/haplogroups/splitBranchForm.scala.html new file mode 100644 index 0000000..db4af6f --- /dev/null +++ b/app/views/curator/haplogroups/splitBranchForm.scala.html @@ -0,0 +1,180 @@ +@import org.webjars.play.WebJarsUtil +@import controllers.SplitBranchFormData +@import models.domain.haplogroups.Haplogroup +@import models.domain.genomics.VariantGroup +@(parent: Haplogroup, variantGroups: Seq[VariantGroup], children: Seq[Haplogroup], form: Form[SplitBranchFormData])(implicit request: RequestHeader, messages: Messages, webJarsUtil: WebJarsUtil) + +@main(s"Split ${parent.name}") { +
+
+
+ + +
+
+
Create Subclade under @parent.name
+
+
+ @form.globalError.map { error => +
@error.message
+ } + + @helper.form(controllers.routes.CuratorController.splitBranch(parent.id.get)) { + @helper.CSRF.formField + +
New Subclade Details
+ +
+ + + @form.error("name").map { error => +
@error.message
+ } +
+ +
+ + +
+ +
+ + +
+ +
+
+ + + @form.error("source").map { error => +
@error.message
+ } +
+
+ + + @form.error("confidenceLevel").map { error => +
@error.message
+ } +
+
+ +
+ + @if(variantGroups.nonEmpty) { +
+ Variants to MOVE to new subclade + (will be removed from @parent.name) +
+ +
+ @for(group <- variantGroups) { +
+ form(s"variantGroupKeys[$i]").value).contains(group.groupKey)){checked}> + +
+ } +
+ } else { +
+ No variants currently associated with @parent.name. +
+ } + +
+ + @if(children.nonEmpty) { +
+ Children to re-parent under new subclade + (will become children of new subclade) +
+ +
+ @for(child <- children) { +
+ form(s"childIds[$i]").value).map(_.toInt).contains(child.id.get)){checked}> + +
+ } +
+ } else { +
+ @parent.name has no children. +
+ } + +
+ + + Cancel + +
+ } +
+
+
+
+
+} diff --git a/app/views/curator/haplogroups/variantHistoryPanel.scala.html b/app/views/curator/haplogroups/variantHistoryPanel.scala.html new file mode 100644 index 0000000..b1409ed --- /dev/null +++ b/app/views/curator/haplogroups/variantHistoryPanel.scala.html @@ -0,0 +1,42 @@ +@import models.domain.haplogroups.HaplogroupVariantMetadata +@(haplogroupVariantId: Int, history: Seq[HaplogroupVariantMetadata])(implicit request: RequestHeader) + +
+
+
Haplogroup-Variant History
+
+
+ @if(history.isEmpty) { +

No revision history available.

+ } else { + + } +
+
+ +@changeTypeBadgeClass(changeType: String) = @{ + changeType match { + case "add" => "bg-success" + case "remove" => "bg-danger" + case "update" => "bg-warning text-dark" + case _ => "bg-secondary" + } +} diff --git a/app/views/curator/haplogroups/variantSearchResults.scala.html b/app/views/curator/haplogroups/variantSearchResults.scala.html new file mode 100644 index 0000000..9077e9b --- /dev/null +++ b/app/views/curator/haplogroups/variantSearchResults.scala.html @@ -0,0 +1,67 @@ +@import models.domain.genomics.VariantGroup +@(haplogroupId: Int, haplogroupName: String, query: Option[String], variantGroups: Seq[VariantGroup])(implicit request: RequestHeader) + +
+ + +
Enter rsId (rs...) or common name to search. All reference builds will be added automatically.
+
+ +@if(query.exists(_.nonEmpty)) { + @if(variantGroups.isEmpty) { +
+ No matching variants found that aren't already associated with @haplogroupName. +
+ } else { +
+ @for(group <- variantGroups.take(10)) { +
+
+
+ @group.displayName + @group.rsId.filter(_ != group.displayName).map { rs => + (@rs) + } + @group.buildCount build@if(group.buildCount != 1){s} +
+ +
+
+ @for(vwc <- group.variantsSorted) { +
+ @vwc.shortReferenceGenome + @vwc.formattedPosition + @vwc.variant.referenceAllele→@vwc.variant.alternateAllele +
+ } +
+
+ } +
+ @if(variantGroups.size > 10) { +
+ Showing first 10 of @variantGroups.size results. Refine your search for more specific results. +
+ } + } +} else { +
+ Start typing to search for variants... +
+} diff --git a/app/views/curator/haplogroups/variantsPanel.scala.html b/app/views/curator/haplogroups/variantsPanel.scala.html new file mode 100644 index 0000000..85bd45f --- /dev/null +++ b/app/views/curator/haplogroups/variantsPanel.scala.html @@ -0,0 +1,121 @@ +@import models.domain.genomics.VariantGroup +@(haplogroupId: Int, variantGroups: Seq[VariantGroup])(implicit request: RequestHeader) + +
+
+ Defining Variants + @variantGroups.size +
+ +
+ +@if(variantGroups.isEmpty) { +

No variants associated with this haplogroup.

+} else { + @defining(s"variants-$haplogroupId") { containerId => +
+ +
+ +
+ +
+ + + } + + +} + + + diff --git a/app/views/curator/variants/createForm.scala.html b/app/views/curator/variants/createForm.scala.html new file mode 100644 index 0000000..3659773 --- /dev/null +++ b/app/views/curator/variants/createForm.scala.html @@ -0,0 +1,149 @@ +@import org.webjars.play.WebJarsUtil +@import controllers.VariantFormData +@import models.domain.genomics.GenbankContig +@(form: Form[VariantFormData], contigs: Seq[GenbankContig])(implicit request: RequestHeader, messages: Messages, webJarsUtil: WebJarsUtil) + +@contigLabel(c: GenbankContig) = @{ + val refGenome = c.referenceGenome.getOrElse("Unknown") + val name = c.commonName.getOrElse(c.accession) + s"$refGenome:$name" +} + +@main("Create Variant") { +
+
+
+ + +
+
+
Create Variant
+
+
+ @helper.form(controllers.routes.CuratorController.createVariant) { + @helper.CSRF.formField + +
+
+ + + @form.error("genbankContigId").map { error => +
@error.message
+ } +
+
+ + + @form.error("position").map { error => +
@error.message
+ } +
+
+ +
+
+ + +
The allele present in the reference genome (ancestral state)
+ @form.error("referenceAllele").map { error => +
@error.message
+ } +
+
+ + +
The mutated allele that defines this variant (derived state)
+ @form.error("alternateAllele").map { error => +
@error.message
+ } +
+
+ +
+ + + @form.error("variantType").map { error => +
@error.message
+ } +
+ +
+
+ + +
+
+ + +
+
+ +
+ + + Cancel + +
+ } +
+
+
+
+
+} diff --git a/app/views/curator/variants/detailPanel.scala.html b/app/views/curator/variants/detailPanel.scala.html new file mode 100644 index 0000000..3ad0381 --- /dev/null +++ b/app/views/curator/variants/detailPanel.scala.html @@ -0,0 +1,202 @@ +@import models.domain.genomics.{VariantWithContig, VariantGroup} +@import models.dal.domain.genomics.VariantAlias +@import models.domain.haplogroups.Haplogroup +@import models.domain.curator.AuditLogEntry +@(vwc: VariantWithContig, variantGroup: Option[VariantGroup], aliases: Seq[VariantAlias], haplogroups: Seq[Haplogroup], history: Seq[AuditLogEntry])(implicit request: RequestHeader) + +
+
+
@vwc.variant.rsId.orElse(vwc.variant.commonName).getOrElse(s"Variant ${vwc.variant.variantId.get}")
+ @vwc.variant.variantType +
+
+
+
rsId
+
@vwc.variant.rsId.getOrElse("-")
+ +
Common Name
+
@vwc.variant.commonName.getOrElse("-")
+ +
Ancestral
+
@vwc.variant.referenceAllele
+ +
Derived
+
@vwc.variant.alternateAllele
+ +
Type
+
@vwc.variant.variantType
+
+ + @if(aliases.nonEmpty) { +
+
Alternative Names
+ @defining(aliases.groupBy(_.aliasType)) { aliasesByType => +
+ @for((aliasType, typeAliases) <- aliasesByType) { +
+ @formatAliasType(aliasType): + @for(alias <- typeAliases) { + + @alias.aliasValue + @if(alias.isPrimary){} + + } +
+ } +
+ } + } + +
+ +
Reference Builds
+ @variantGroup match { + case Some(group) if group.variants.size > 1 => { + @defining(group.variantsSorted.head) { first => + @if(group.variantsSorted.tail.exists(v => v.variant.referenceAllele != first.variant.referenceAllele || v.variant.alternateAllele != first.variant.alternateAllele)) { +
+ Strand difference (reverse complement) +
+ } + } +
+ + + + + + + + + + @for(v <- group.variantsSorted) { + + + + + + } + +
BuildPositionAlleles
+ + @v.shortReferenceGenome + + @v.formattedPosition + @v.variant.referenceAllele + + @v.variant.alternateAllele +
+
+ } + case _ => { +
+ @vwc.shortReferenceGenome + @vwc.formattedPosition + + @vwc.variant.referenceAllele + + @vwc.variant.alternateAllele + +
+ } + } + +
+ +
Used By Haplogroups
+ @if(haplogroups.isEmpty) { +

This variant is not associated with any haplogroups.

+ } else { +
+ @for(hg <- haplogroups.take(10)) { + + @hg.name + + } + @if(haplogroups.size > 10) { + +@(haplogroups.size - 10) more + } +
+ } + +
+ +
+ @variantGroup match { + case Some(group) if group.variants.size > 1 => { + + Edit Group (@group.variants.size builds) + + } + case _ => { + + Edit + + } + } + +
+ + @if(history.nonEmpty) { +
+
Recent History
+ + @if(history.size > 5) { + + View all history... + + } + } +
+
+ +@formatAliasType(aliasType: String) = @{ + aliasType match { + case "common_name" => "SNP Names" + case "rs_id" => "dbSNP IDs" + case "isogg" => "ISOGG" + case "yfull" => "YFull" + case "ftdna" => "FTDNA" + case other => other.replace("_", " ").capitalize + } +} + +@buildBadgeClass(refGenome: String) = @{ + refGenome match { + case "GRCh37" => "bg-warning text-dark" + case "GRCh38" => "bg-info" + case "T2T" => "bg-success" + case _ => "bg-secondary" + } +} + +@actionBadgeClass(action: String) = @{ + action match { + case "create" => "bg-success" + case "update" => "bg-warning text-dark" + case "delete" => "bg-danger" + case _ => "bg-secondary" + } +} diff --git a/app/views/curator/variants/editForm.scala.html b/app/views/curator/variants/editForm.scala.html new file mode 100644 index 0000000..cad9448 --- /dev/null +++ b/app/views/curator/variants/editForm.scala.html @@ -0,0 +1,116 @@ +@import org.webjars.play.WebJarsUtil +@import controllers.VariantFormData +@(id: Int, form: Form[VariantFormData])(implicit request: RequestHeader, messages: Messages, webJarsUtil: WebJarsUtil) + +@main("Edit Variant") { +
+
+
+ + +
+
+
Edit Variant
+
+
+ @helper.form(controllers.routes.CuratorController.updateVariant(id)) { + @helper.CSRF.formField + +
+
+ + + +
+
+ + + +
+
+ +
+
+ + + +
+
+ + + +
+
+ + Genomic coordinates cannot be changed after creation + +
+ + + @form.error("variantType").map { error => +
@error.message
+ } +
+ +
+
+ + +
+
+ + +
+
+ +
+ + + Cancel + +
+ } +
+
+
+
+
+} diff --git a/app/views/curator/variants/editGroupForm.scala.html b/app/views/curator/variants/editGroupForm.scala.html new file mode 100644 index 0000000..92d588b --- /dev/null +++ b/app/views/curator/variants/editGroupForm.scala.html @@ -0,0 +1,156 @@ +@import org.webjars.play.WebJarsUtil +@import controllers.VariantFormData +@import models.domain.genomics.VariantGroup +@(groupKey: String, variantGroup: VariantGroup, form: Form[VariantFormData])(implicit request: RequestHeader, messages: Messages, webJarsUtil: WebJarsUtil) + +@buildBadgeClass(refGenome: String) = @{ + refGenome match { + case "GRCh37" => "bg-warning text-dark" + case "GRCh38" => "bg-info" + case "T2T" => "bg-success" + case _ => "bg-secondary" + } +} + +@* Check if alleles differ from first variant (potential reverse complement) *@ +@hasAlleleDifference = @{ + variantGroup.variants.size > 1 && { + val first = variantGroup.variantsSorted.head + variantGroup.variantsSorted.tail.exists { v => + v.variant.referenceAllele != first.variant.referenceAllele || + v.variant.alternateAllele != first.variant.alternateAllele + } + } +} + +@main(s"Edit Variant Group: ${variantGroup.displayName}") { +
+
+
+ + +
+
+
+ Edit Variant Group + @variantGroup.variants.size builds +
+
+
+
+ + Editing this group will update @variantGroup.variants.size variant@if(variantGroup.variants.size != 1){s} across different reference builds. +
+ + @if(hasAlleleDifference) { +
+ + Strand difference detected: Alleles vary between builds (likely reverse complement on different strand). +
+ } + +
Variants in this group
+
+ + + + + + + + + + @for(vwc <- variantGroup.variantsSorted) { + + + + + + } + +
BuildPositionAlleles (Anc→Der)
+ + @vwc.shortReferenceGenome + + + @vwc.contig.commonName.getOrElse(vwc.contig.accession):@vwc.variant.position + + @vwc.variant.referenceAllele + + @vwc.variant.alternateAllele +
+
+ +
+ +
Shared Properties (applies to all builds)
+ + @helper.form(controllers.routes.CuratorController.updateVariantGroup(groupKey)) { + @helper.CSRF.formField + + @* Hidden fields to preserve required form data *@ + + + + + +
+ + + @form.error("variantType").map { error => +
@error.message
+ } +
+ +
+
+ + +
dbSNP reference identifier
+
+
+ + +
SNP name used in phylogenetic trees
+
+
+ +
+ + + Cancel + +
+ } +
+
+
+
+
+} diff --git a/app/views/curator/variants/list.scala.html b/app/views/curator/variants/list.scala.html new file mode 100644 index 0000000..d7589be --- /dev/null +++ b/app/views/curator/variants/list.scala.html @@ -0,0 +1,90 @@ +@import org.webjars.play.WebJarsUtil +@import models.domain.genomics.VariantGroup +@(variantGroups: Seq[VariantGroup], query: Option[String], currentPage: Int, totalPages: Int, pageSize: Int)(implicit request: RequestHeader, messages: Messages, webJarsUtil: WebJarsUtil) + +@main("Curator - Variants") { + + +
+
+
+ +
+
+ + @request.flash.get("success").map { msg => + + } + +
+
+
+
+
Variants
+ + Create + +
+
+
+
+ +
+
+ +
+ @listFragment(variantGroups, query, currentPage, totalPages, pageSize) +
+
+
+
+ +
+
+
+
+ +

Select a variant to view details

+
+
+
+
+
+
+ + +} diff --git a/app/views/curator/variants/listFragment.scala.html b/app/views/curator/variants/listFragment.scala.html new file mode 100644 index 0000000..80974b6 --- /dev/null +++ b/app/views/curator/variants/listFragment.scala.html @@ -0,0 +1,115 @@ +@import models.domain.genomics.VariantGroup +@(variantGroups: Seq[VariantGroup], query: Option[String], currentPage: Int, totalPages: Int, pageSize: Int)(implicit request: RequestHeader) + +@if(variantGroups.isEmpty) { +
No variants found matching your criteria.
+} else { +
+ + + + + + + + + + + + @for(group <- variantGroups) { + @* Use first variant for display, clicking shows group detail *@ + @defining(group.variantsSorted.headOption) { firstOpt => + @firstOpt.map { first => + + + + + + + + } + } + } + +
rsId / NameAnc/DerTypeBuilds
+ @group.displayName + @group.rsId.filter(_ => group.commonName.isDefined).map { rsId => +
@rsId + } +
+ @first.variant.referenceAllele@first.variant.alternateAllele + @if(group.variants.size > 1 && group.variantsSorted.exists(v => v.variant.referenceAllele != first.variant.referenceAllele)) { + + } + + @first.variant.variantType + + @for(vwc <- group.variantsSorted) { + + @vwc.shortReferenceGenome + + } + + @if(group.variants.size > 1) { + + + + } else { + + + + } +
+
+ + @if(totalPages > 1) { + + } +} + +@buildBadgeClass(refGenome: String) = @{ + refGenome match { + case "GRCh37" => "bg-warning text-dark" + case "GRCh38" => "bg-info" + case "T2T" => "bg-success" + case _ => "bg-secondary" + } +} diff --git a/app/views/fragments/haplogroup.scala.html b/app/views/fragments/haplogroup.scala.html index 3bde1e5..e1996b9 100644 --- a/app/views/fragments/haplogroup.scala.html +++ b/app/views/fragments/haplogroup.scala.html @@ -4,7 +4,7 @@ @import models.api.TreeDTO @import models.view.TreeViewModel -@(tree: TreeDTO, hapType: HaplogroupType, renderedTreeData: Option[TreeViewModel], currentUrl: String)(implicit messages: Messages) +@(tree: TreeDTO, hapType: HaplogroupType, renderedTreeData: Option[TreeViewModel], currentUrl: String, showBranchAgeEstimates: Boolean = false)(implicit messages: Messages) @fullPageUrl(haplogroup: Option[String]) = @{ hapType match { @@ -41,16 +41,20 @@
- @messages("tree.legend.established") - @messages("tree.legend.established") + @messages("tree.legend.updated") + background-color: #ffeeba; + border: 2px dashed #e65100; + border-radius: 3px; + vertical-align: middle;"> @messages("tree.legend.updated")
@@ -65,37 +69,56 @@ @for(node <- rtd.allNodes) { - + style="fill: @node.fillColor; cursor: pointer;"> @node.name (@messages("tree.reRoot")) + @* Header bar *@ + + + @* Name row with icon *@ - @node.name + x="@(node.y)" + y="@(node.x - 25)" + text-anchor="middle"> + @if(node.isBackbone){✓ }@if(node.isRecentlyUpdated){★ }@node.name + + @* Variants row *@ + + @node.variantsCount.map(c => s"$c variants ▸").getOrElse("—") + @messages("tree.clickToSeeVariants", node.variantsCount.getOrElse(0)) - @if(node.variantsCount.nonEmpty) { - - @node.variantsCount.get - @messages("tree.clickToSeeVariants", node.variantsCount.get) + @if(showBranchAgeEstimates) { + @* Formed row *@ + + Formed: @node.formedFormatted.getOrElse("—") + + @* TMRCA row *@ + + TMRCA: @node.tmrcaFormatted.getOrElse("—") } @@ -131,25 +154,59 @@ .node { - cursor: default; /* Indicate no clickability for now */ + cursor: default; } .node-rect { - fill: #f8f8f8; /* Default light grey background for boxes */ - stroke: #333; /* Darker border */ + fill: #f8f8f8; + stroke: #999; stroke-width: 1px; } + .node-rect.node-established { + stroke: #2e7d32; + stroke-width: 2px; + } + + .node-rect.node-recent { + stroke: #e65100; + stroke-width: 2px; + stroke-dasharray: 4 2; + } + + .node-header { + pointer-events: none; + } + .node-name { font-family: sans-serif; font-size: 12px; - fill: black; + font-weight: bold; + fill: white; + pointer-events: none; + } + + .node-detail { + font-family: sans-serif; + font-size: 11px; + fill: #333; } - .node-variant-count { + .node-variant-link { font-family: sans-serif; + font-size: 11px; + fill: #0077b6; + text-decoration: underline; + cursor: pointer; + } + + .node-variant-link:hover { + fill: #023e8a; + } + + .node-date { font-size: 10px; - fill: #6c757d; + fill: #666; } .link { diff --git a/app/views/fragments/snpDetailSidebar.scala.html b/app/views/fragments/snpDetailSidebar.scala.html index cdb9a6d..4bdb0b3 100644 --- a/app/views/fragments/snpDetailSidebar.scala.html +++ b/app/views/fragments/snpDetailSidebar.scala.html @@ -15,24 +15,40 @@
@messages("sidebar.title", haplogroupName)
@if(snps.isEmpty) {

@messages("sidebar.noVariants", haplogroupName)

} else { - + } @@ -75,18 +91,98 @@
@messages("sidebar.title", haplogroupName)
color: #555; } - .snp-list { - list-style: none; - padding: 0; - margin: 0; + .snp-cards { + display: flex; + flex-direction: column; + gap: 10px; } - .snp-list li { + .snp-card { background-color: #ffffff; border: 1px solid #eee; - margin-bottom: 10px; - padding: 10px; - border-radius: 4px; - font-size: 0.9em; + border-radius: 6px; + overflow: hidden; + } + + .snp-card-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 12px; + background-color: #f5f5f5; + border-bottom: 1px solid #eee; + } + + .snp-type-badge { + font-size: 0.75em; + padding: 2px 6px; + background-color: #6c757d; + color: white; + border-radius: 3px; + } + + .snp-card-body { + padding: 8px 12px; + } + + .snp-coord-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 4px 0; + font-size: 0.85em; + border-bottom: 1px solid #f0f0f0; + } + + .snp-coord-row:last-child { + border-bottom: none; + } + + .snp-ref { + color: #666; + flex: 1; + } + + .snp-pos { + font-family: monospace; + text-align: right; + flex: 0 0 auto; + margin: 0 12px; + } + + .snp-alleles { + font-family: monospace; + text-align: right; + flex: 0 0 60px; + } + + .snp-card-footer { + padding: 8px 12px; + background-color: #fafafa; + border-top: 1px solid #eee; + font-size: 0.8em; + color: #666; + } + + .alias-group { + display: block; + margin-left: 10px; + margin-top: 4px; + } + + .alias-type { + color: #888; + font-style: italic; } + +@formatAliasType(aliasType: String) = @{ + aliasType match { + case "common_name" => "SNP Names" + case "rs_id" => "dbSNP IDs" + case "isogg" => "ISOGG" + case "yfull" => "YFull" + case "ftdna" => "FTDNA" + case other => other.replace("_", " ").capitalize + } +} diff --git a/build.sbt b/build.sbt index 03e8779..649eb37 100644 --- a/build.sbt +++ b/build.sbt @@ -57,6 +57,7 @@ libraryDependencies ++= Seq( "software.amazon.awssdk" % "ses" % AWS_VERSION, "org.hashids" % "hashids" % "1.0.3", "org.mindrot" % "jbcrypt" % "0.4", // BCrypt for password hashing + "com.github.samtools" % "htsjdk" % "4.3.0", "org.scalatestplus" %% "mockito-5-10" % "3.2.18.0" % Test ) diff --git a/conf/application.conf b/conf/application.conf index 66ee2f9..dee143c 100644 --- a/conf/application.conf +++ b/conf/application.conf @@ -105,6 +105,13 @@ pekko { timezone = "UTC" description = "Discover new publications via OpenAlex" } + + YBrowseVariantUpdate { + # Run weekly on Monday at 3 AM UTC + expression = "0 0 3 ? * MON" + timezone = "UTC" + description = "Download and ingest Y-DNA SNP data from YBrowse" + } } } @@ -125,4 +132,55 @@ aws { biosample.hash.salt = "your-biosample-salt" biosample.hash.salt = ${?BIOSAMPLE_HASH_SALT} +# Feature flags - enable/disable features in development +features { + tree { + # Show branch age estimates (Formed/TMRCA) on tree nodes + # Disabled until age data is populated + showBranchAgeEstimates = false + showBranchAgeEstimates = ${?FEATURE_SHOW_BRANCH_AGE_ESTIMATES} + } +} + +genomics { + references { + # Canonical names for supported linear reference builds + supported = ["GRCh37", "GRCh38", "hs1"] + + # Aliases to map common names to canonical names + aliases { + "hg19" = "GRCh37" + "hg38" = "GRCh38" + "chm13" = "hs1" + "chm13v2.0" = "hs1" + } + + # Paths to reference genome FASTA files + fasta_paths { + "GRCh37" = "conf/fasta/GRCh37.fa" + "GRCh38" = "conf/fasta/GRCh38.fa" + "hs1" = "conf/fasta/hs1.fa" + } + } + + liftover { + # Chain files for coordinate conversion + # Format: "Source->Target" = "path/to/chain/file" + chains { + "GRCh38->GRCh37" = "conf/liftover/hg38ToHg19.over.chain.gz" + "GRCh38->hs1" = "conf/liftover/hg38ToHs1.over.chain.gz" + } + } + + ybrowse { + # URL to download the YBrowse Y-DNA SNP VCF file + vcf_url = "https://ybrowse.org/gbrowse2/gff/snps_hg38.vcf.gz" + vcf_url = ${?YBROWSE_VCF_URL} + + # Local storage path for downloaded VCF file + vcf_storage_path = "/var/lib/decodingus/ybrowse/snps_hg38.vcf.gz" + vcf_storage_path = ${?YBROWSE_VCF_STORAGE_PATH} + } +} + diff --git a/conf/evolutions/default/46.sql b/conf/evolutions/default/46.sql new file mode 100644 index 0000000..17aa03f --- /dev/null +++ b/conf/evolutions/default/46.sql @@ -0,0 +1,72 @@ +-- !Ups + +-- Add TreeCurator role +INSERT INTO auth.roles (id, name, description, created_at, updated_at) +VALUES (gen_random_uuid(), 'TreeCurator', 'Curator access for haplogroups and variants', NOW(), NOW()) +ON CONFLICT (name) DO NOTHING; + +-- Create curator permissions +INSERT INTO auth.permissions (id, name, description, created_at, updated_at) VALUES + (gen_random_uuid(), 'haplogroup.view', 'View haplogroup details', NOW(), NOW()), + (gen_random_uuid(), 'haplogroup.create', 'Create new haplogroups', NOW(), NOW()), + (gen_random_uuid(), 'haplogroup.update', 'Update existing haplogroups', NOW(), NOW()), + (gen_random_uuid(), 'haplogroup.delete', 'Delete haplogroups', NOW(), NOW()), + (gen_random_uuid(), 'variant.view', 'View variant details', NOW(), NOW()), + (gen_random_uuid(), 'variant.create', 'Create new variants', NOW(), NOW()), + (gen_random_uuid(), 'variant.update', 'Update existing variants', NOW(), NOW()), + (gen_random_uuid(), 'variant.delete', 'Delete variants', NOW(), NOW()), + (gen_random_uuid(), 'audit.view', 'View audit history', NOW(), NOW()) +ON CONFLICT (name) DO NOTHING; + +-- Grant all curator permissions to TreeCurator role +INSERT INTO auth.role_permissions (role_id, permission_id) +SELECT r.id, p.id FROM auth.roles r, auth.permissions p +WHERE r.name = 'TreeCurator' + AND p.name IN ('haplogroup.view', 'haplogroup.create', 'haplogroup.update', 'haplogroup.delete', + 'variant.view', 'variant.create', 'variant.update', 'variant.delete', 'audit.view') +ON CONFLICT DO NOTHING; + +-- Grant all curator permissions to Admin role +INSERT INTO auth.role_permissions (role_id, permission_id) +SELECT r.id, p.id FROM auth.roles r, auth.permissions p +WHERE r.name = 'Admin' + AND p.name IN ('haplogroup.view', 'haplogroup.create', 'haplogroup.update', 'haplogroup.delete', + 'variant.view', 'variant.create', 'variant.update', 'variant.delete', 'audit.view') +ON CONFLICT DO NOTHING; + +-- Create curator schema +CREATE SCHEMA IF NOT EXISTS curator; + +-- Create audit_log table +CREATE TABLE curator.audit_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL, + entity_type VARCHAR(50) NOT NULL, + entity_id INT NOT NULL, + action VARCHAR(20) NOT NULL, + old_value JSONB, + new_value JSONB, + comment TEXT, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + CONSTRAINT fk_audit_log_user_id FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE SET NULL +); + +CREATE INDEX idx_audit_log_entity ON curator.audit_log(entity_type, entity_id); +CREATE INDEX idx_audit_log_user ON curator.audit_log(user_id); +CREATE INDEX idx_audit_log_created_at ON curator.audit_log(created_at DESC); + +COMMENT ON TABLE curator.audit_log IS 'Audit trail for all curator actions on haplogroups and variants'; + +-- !Downs + +DROP TABLE IF EXISTS curator.audit_log; +DROP SCHEMA IF EXISTS curator; + +DELETE FROM auth.role_permissions +WHERE permission_id IN (SELECT id FROM auth.permissions WHERE name LIKE 'haplogroup.%' OR name LIKE 'variant.%' OR name = 'audit.view'); + +DELETE FROM auth.permissions +WHERE name IN ('haplogroup.view', 'haplogroup.create', 'haplogroup.update', 'haplogroup.delete', + 'variant.view', 'variant.create', 'variant.update', 'variant.delete', 'audit.view'); + +DELETE FROM auth.roles WHERE name = 'TreeCurator'; diff --git a/conf/evolutions/default/47.sql b/conf/evolutions/default/47.sql new file mode 100644 index 0000000..4acc1e6 --- /dev/null +++ b/conf/evolutions/default/47.sql @@ -0,0 +1,36 @@ +# --- !Ups + +-- Variant Alias Table +-- Stores alternative names for variants from different sources (YBrowse, ISOGG, YFull, publications, etc.) +-- A single variant may be known by multiple names across different research groups. + +CREATE TABLE variant_alias ( + id SERIAL PRIMARY KEY, + variant_id INT NOT NULL REFERENCES variant(variant_id) ON DELETE CASCADE, + alias_type VARCHAR(50) NOT NULL, -- 'common_name', 'rs_id', 'isogg', 'yfull', 'ftdna', etc. + alias_value VARCHAR(255) NOT NULL, + source VARCHAR(255), -- Origin: 'ybrowse', 'isogg', 'curator', 'yfull', etc. + is_primary BOOLEAN DEFAULT FALSE, -- Primary alias for this type (for display preference) + created_at TIMESTAMP DEFAULT NOW() NOT NULL, + UNIQUE(variant_id, alias_type, alias_value) +); + +CREATE INDEX idx_variant_alias_variant ON variant_alias(variant_id); +CREATE INDEX idx_variant_alias_value ON variant_alias(alias_value); +CREATE INDEX idx_variant_alias_type_value ON variant_alias(alias_type, alias_value); + +-- Migrate existing names to alias table +-- This preserves the current common_name and rs_id as aliases +INSERT INTO variant_alias (variant_id, alias_type, alias_value, source, is_primary) +SELECT variant_id, 'common_name', common_name, 'migration', TRUE +FROM variant +WHERE common_name IS NOT NULL; + +INSERT INTO variant_alias (variant_id, alias_type, alias_value, source, is_primary) +SELECT variant_id, 'rs_id', rs_id, 'migration', TRUE +FROM variant +WHERE rs_id IS NOT NULL; + +# --- !Downs + +DROP TABLE IF EXISTS variant_alias; diff --git a/conf/evolutions/default/48.sql b/conf/evolutions/default/48.sql new file mode 100644 index 0000000..f38a4da --- /dev/null +++ b/conf/evolutions/default/48.sql @@ -0,0 +1,32 @@ +-- # --- !Ups + +-- Add branch age estimate columns to haplogroup table +-- Dates stored as years before present (YBP) with optional confidence intervals + +ALTER TABLE tree.haplogroup + ADD COLUMN formed_ybp INTEGER, + ADD COLUMN formed_ybp_lower INTEGER, + ADD COLUMN formed_ybp_upper INTEGER, + ADD COLUMN tmrca_ybp INTEGER, + ADD COLUMN tmrca_ybp_lower INTEGER, + ADD COLUMN tmrca_ybp_upper INTEGER, + ADD COLUMN age_estimate_source VARCHAR(100); + +COMMENT ON COLUMN tree.haplogroup.formed_ybp IS 'Estimated years before present when branch formed (mutation occurred)'; +COMMENT ON COLUMN tree.haplogroup.formed_ybp_lower IS 'Lower bound of 95% confidence interval for formed date'; +COMMENT ON COLUMN tree.haplogroup.formed_ybp_upper IS 'Upper bound of 95% confidence interval for formed date'; +COMMENT ON COLUMN tree.haplogroup.tmrca_ybp IS 'Estimated years before present for Time to Most Recent Common Ancestor'; +COMMENT ON COLUMN tree.haplogroup.tmrca_ybp_lower IS 'Lower bound of 95% confidence interval for TMRCA'; +COMMENT ON COLUMN tree.haplogroup.tmrca_ybp_upper IS 'Upper bound of 95% confidence interval for TMRCA'; +COMMENT ON COLUMN tree.haplogroup.age_estimate_source IS 'Source of age estimates (e.g., YFull, internal calculation)'; + +-- # --- !Downs + +ALTER TABLE tree.haplogroup + DROP COLUMN IF EXISTS formed_ybp, + DROP COLUMN IF EXISTS formed_ybp_lower, + DROP COLUMN IF EXISTS formed_ybp_upper, + DROP COLUMN IF EXISTS tmrca_ybp, + DROP COLUMN IF EXISTS tmrca_ybp_lower, + DROP COLUMN IF EXISTS tmrca_ybp_upper, + DROP COLUMN IF EXISTS age_estimate_source; diff --git a/conf/routes b/conf/routes index b16635b..6b6556a 100644 --- a/conf/routes +++ b/conf/routes @@ -107,6 +107,9 @@ POST /api/firehose/event # Publication Discovery POST /api/private/publication-discovery/run controllers.PublicationDiscoveryController.triggerDiscovery() +# Genomics Admin +POST /api/private/genomics/ybrowse/update controllers.GenomicsAdminController.triggerYBrowseUpdate() + # Authentication GET /login controllers.AuthController.login POST /login controllers.AuthController.authenticate @@ -121,11 +124,55 @@ POST /cookies/accept GET /profile controllers.ProfileController.view POST /profile controllers.ProfileController.update +# Variant API +GET /api/v1/variants/search controllers.VariantController.search(name: String) + # Curator Workflow GET /admin/publication-candidates controllers.PublicationCandidateController.listCandidates(page: Int ?= 1, pageSize: Int ?= 20) POST /admin/publication-candidates/:id/accept controllers.PublicationCandidateController.accept(id: Int) POST /admin/publication-candidates/:id/reject controllers.PublicationCandidateController.reject(id: Int) +# Curator Tools (requires TreeCurator or Admin role) +GET /curator controllers.CuratorController.dashboard + +# Curator - Haplogroups +GET /curator/haplogroups controllers.CuratorController.listHaplogroups(query: Option[String], hgType: Option[String], page: Int ?= 1, pageSize: Int ?= 20) +GET /curator/haplogroups/fragment controllers.CuratorController.haplogroupsFragment(query: Option[String], hgType: Option[String], page: Int ?= 1, pageSize: Int ?= 20) +GET /curator/haplogroups/search.json controllers.CuratorController.searchHaplogroupsJson(query: Option[String], hgType: Option[String]) +GET /curator/haplogroups/new controllers.CuratorController.createHaplogroupForm +POST /curator/haplogroups controllers.CuratorController.createHaplogroup +GET /curator/haplogroups/:id/panel controllers.CuratorController.haplogroupDetailPanel(id: Int) +GET /curator/haplogroups/:id/edit controllers.CuratorController.editHaplogroupForm(id: Int) +POST /curator/haplogroups/:id controllers.CuratorController.updateHaplogroup(id: Int) +DELETE /curator/haplogroups/:id controllers.CuratorController.deleteHaplogroup(id: Int) + +# Curator - Haplogroup Tree Restructuring +GET /curator/haplogroups/:id/split controllers.CuratorController.splitBranchForm(id: Int) +POST /curator/haplogroups/:id/split controllers.CuratorController.splitBranch(id: Int) +GET /curator/haplogroups/:id/merge controllers.CuratorController.mergeConfirmForm(id: Int) +POST /curator/haplogroups/:id/merge controllers.CuratorController.mergeIntoParent(id: Int) + +# Curator - Haplogroup-Variant Associations +GET /curator/haplogroups/:id/variants/search controllers.CuratorController.searchVariantsForHaplogroup(id: Int, q: Option[String]) +POST /curator/haplogroups/:hgId/variant-groups/:groupKey controllers.CuratorController.addVariantGroupToHaplogroup(hgId: Int, groupKey: String) +DELETE /curator/haplogroups/:hgId/variant-groups/:groupKey controllers.CuratorController.removeVariantGroupFromHaplogroup(hgId: Int, groupKey: String) +GET /curator/haplogroup-variants/:hvId/history controllers.CuratorController.haplogroupVariantHistory(hvId: Int) + +# Curator - Variants +GET /curator/variants controllers.CuratorController.listVariants(query: Option[String], page: Int ?= 1, pageSize: Int ?= 20) +GET /curator/variants/fragment controllers.CuratorController.variantsFragment(query: Option[String], page: Int ?= 1, pageSize: Int ?= 20) +GET /curator/variants/new controllers.CuratorController.createVariantForm +POST /curator/variants controllers.CuratorController.createVariant +GET /curator/variants/:id/panel controllers.CuratorController.variantDetailPanel(id: Int) +GET /curator/variants/:id/edit controllers.CuratorController.editVariantForm(id: Int) +POST /curator/variants/:id controllers.CuratorController.updateVariant(id: Int) +DELETE /curator/variants/:id controllers.CuratorController.deleteVariant(id: Int) +GET /curator/variants/group/:groupKey/edit controllers.CuratorController.editVariantGroupForm(groupKey: String) +POST /curator/variants/group/:groupKey controllers.CuratorController.updateVariantGroup(groupKey: String) + +# Curator - Audit +GET /curator/audit/:entityType/:entityId controllers.CuratorController.auditHistory(entityType: String, entityId: Int) + # --- API Routes (Handled by Tapir, including Swagger UI) --- POST /api/registerPDS controllers.PDSRegistrationController.registerPDS() diff --git a/documents/planning/haplogroup-discovery-system.md b/documents/planning/haplogroup-discovery-system.md index 268bf2a..a24c07c 100644 --- a/documents/planning/haplogroup-discovery-system.md +++ b/documents/planning/haplogroup-discovery-system.md @@ -1337,7 +1337,7 @@ decodingus.discovery { - [X] Updated Slick table definitions with `Some("tree")` schema parameter - [X] Updated `DatabaseSchema.scala` with tree schema table references - [X] Updated all haplogroup repositories for cross-schema queries -- [ ] Regression tests to verify existing functionality +- [X] Regression tests to verify existing functionality - [ ] Documentation update for new schema structure **Risk Mitigation:** @@ -1345,6 +1345,54 @@ decodingus.discovery { - Verify all foreign key constraints work cross-schema - Test recursive CTE queries still function correctly +### Phase 0.5: Base Curator Functionality (COMPLETED) + +**Scope:** +Foundation curator tools for manual tree management, independent of the automated discovery system. These tools allow curators to maintain the haplogroup tree before the discovery system is operational. + +**Deliverables:** + +*Curator Dashboard & Authentication:* +- [X] Permission-based access control (`haplogroup.view`, `haplogroup.create`, `variant.update`, etc.) +- [X] Curator dashboard with counts (Y-DNA haplogroups, mtDNA haplogroups, variants) +- [X] HTMX-powered list/detail views for haplogroups and variants + +*Haplogroup Management:* +- [X] List haplogroups with search, type filter, pagination +- [X] Create haplogroup (restricted to root or terminal leaf placement) +- [X] Create haplogroup above existing root ("Neanderthal > Human" scenario) +- [X] Edit haplogroup metadata (name, lineage, description, source, confidence) +- [X] Delete haplogroup (soft-delete) +- [X] View haplogroup detail panel with children, variants, parent info + +*Variant Management:* +- [X] List variants grouped by commonName/rsId across reference builds (VariantGroup model) +- [X] Create variant with auto-liftover to other reference genomes (GRCh37, GRCh38, T2T-CHM13) +- [X] Edit single variant +- [X] Edit variant group (update all builds simultaneously) +- [X] Delete variant +- [X] Contig selection filtered to Y/MT chromosomes with friendly labels + +*Haplogroup-Variant Associations:* +- [X] Link variants to haplogroups (with source tracking) +- [X] Unlink variants from haplogroups +- [X] Search variants for association + +*Tree Restructuring:* +- [X] Split branch: Create subclade by moving selected variants and/or re-parenting children +- [X] Merge into parent: Absorb child haplogroup (variants move up, grandchildren promoted, child deleted) +- [X] `TreeRestructuringService` with transactional operations + +*Audit Trail:* +- [X] `CuratorAuditService` logging all curator operations +- [X] Audit log entries with action type, before/after state, timestamps +- [X] History panel for haplogroups and variants + +*Views & Routes:* +- [X] Twirl templates for all curator pages (dashboard, lists, forms, detail panels) +- [X] Routes for all CRUD and tree restructuring operations +- [X] JSON endpoints for AJAX operations (haplogroup search, variant search) + ### Phase 1: Data Capture **Scope:** @@ -1383,39 +1431,46 @@ decodingus.discovery { - [ ] `ProposalEngine` service with Jaccard similarity matching - [ ] `ConsensusDetectionService` with unified evidence aggregation -### Phase 3: Curator Workflow +### Phase 3: Curator Workflow (Discovery Proposals) **Scope:** -- Curator API endpoints for proposal management -- Accept/reject/modify operations +- Curator API endpoints for **proposal management** (extends base curator from Phase 0.5) +- Accept/reject/modify operations for automated discovery proposals - Publication bulk upload with private variants -- Audit trail implementation -- Curator dashboard views +- Audit trail for discovery-specific actions +- Proposal review dashboard views + +**Note:** Base curator functionality (haplogroup/variant CRUD, tree restructuring, audit logging) was implemented in Phase 0.5. This phase focuses on the **discovery proposal** workflow. **Deliverables:** -- [ ] Database migration (curator_action, discovery_config tables) -- [ ] `CuratorService` with full proposal lifecycle management -- [ ] `CuratorActionRepository` +- [ ] Database migration (curator_action for discovery, discovery_config tables) +- [ ] `CuratorService` with proposal lifecycle management (accept/reject/modify/split proposals) +- [ ] `CuratorActionRepository` (discovery-specific actions) - [ ] `PublicationUploadService` for bulk biosample+variant uploads -- [ ] Tapir endpoints for curator API +- [ ] Tapir endpoints for proposal management API - [ ] Tapir endpoints for publication upload API -- [ ] Curator authentication/authorization -- [ ] Audit logging +- [X] Curator authentication/authorization - *Implemented in Phase 0.5* +- [X] Audit logging foundation - *Implemented in Phase 0.5 (`CuratorAuditService`)* ### Phase 4: Tree Evolution **Scope:** -- Promotion workflow +- Promotion workflow (automated from discovery proposals) - Tree update mechanics - Biosample reassignment (both sample types) - Reporting isolation - Private variant status updates +**Note:** Manual tree evolution (split/merge) was implemented in Phase 0.5 via `TreeRestructuringService`. This phase focuses on **automated** promotion from discovery proposals. + **Deliverables:** -- [ ] `TreeEvolutionService` with promotion logic +- [ ] `TreeEvolutionService` with automated promotion logic (from proposals) - [ ] Modified tree queries (visibility filter for public vs curator views) - [ ] Biosample reassignment logic (unified across sample types) - [ ] Private variant status transition logic (ACTIVE → PROMOTED) +- [X] Manual tree restructuring: Split branch - *Implemented in Phase 0.5* +- [X] Manual tree restructuring: Merge into parent - *Implemented in Phase 0.5* +- [X] Repository methods: `updateParent`, `createWithParent` - *Implemented in Phase 0.5* - [ ] Integration tests for full workflow (Citizen samples) - [ ] Integration tests for full workflow (External samples) - [ ] Integration tests for mixed-source consensus scenarios @@ -1429,7 +1484,8 @@ decodingus.discovery { - Public tree with proposal indicators (curator view) **Deliverables:** -- [ ] Twirl templates for curator views +- [X] Twirl templates for base curator views (haplogroups, variants, dashboard) - *See Phase 0.5* +- [ ] Twirl templates for proposal review views - [ ] JavaScript for interactive proposal review - [ ] Notification service (email/webhook) - [ ] Tree visualization with proposal overlay