From 6989d885f3a68b7a7c36369d775c0897c9baa201 Mon Sep 17 00:00:00 2001 From: jkane Date: Wed, 10 Dec 2025 05:33:27 -0600 Subject: [PATCH 01/16] feat(genomics): Introduce Variant search API and YBrowse VCF ingestion service - Added `VariantController` with a search endpoint for variants by rsId or common name. - Implemented `YBrowseVariantIngestionService` for batch processing YBrowse VCF files with liftover support. - Expanded `VariantRepository` to support variant search and batch operations. - Updated `GenbankContigRepository` to allow fetching contigs by common names. - Included `htsjdk` library for VCF parsing and liftover functionality. --- app/controllers/VariantController.scala | 30 +++ .../GenbankContigRepository.scala | 13 ++ app/repositories/VariantRepository.scala | 15 ++ .../YBrowseVariantIngestionService.scala | 175 ++++++++++++++++++ build.sbt | 1 + conf/routes | 3 + 6 files changed, 237 insertions(+) create mode 100644 app/controllers/VariantController.scala create mode 100644 app/services/genomics/YBrowseVariantIngestionService.scala 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/repositories/GenbankContigRepository.scala b/app/repositories/GenbankContigRepository.scala index a4ca1b7..a1b9f11 100644 --- a/app/repositories/GenbankContigRepository.scala +++ b/app/repositories/GenbankContigRepository.scala @@ -39,6 +39,14 @@ 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]] } class GenbankContigRepositoryImpl @Inject()( @@ -63,4 +71,9 @@ 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) + } } \ No newline at end of file diff --git a/app/repositories/VariantRepository.scala b/app/repositories/VariantRepository.scala index 16478f0..b688775 100644 --- a/app/repositories/VariantRepository.scala +++ b/app/repositories/VariantRepository.scala @@ -74,6 +74,14 @@ 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]] } class VariantRepositoryImpl @Inject()( @@ -100,6 +108,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) diff --git a/app/services/genomics/YBrowseVariantIngestionService.scala b/app/services/genomics/YBrowseVariantIngestionService.scala new file mode 100644 index 0000000..6310d65 --- /dev/null +++ b/app/services/genomics/YBrowseVariantIngestionService.scala @@ -0,0 +1,175 @@ +package services.genomics + +import htsjdk.samtools.liftover.LiftOver +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 + )(implicit ec: ExecutionContext) { + + private val logger = Logger(this.getClass) + + /** + * Ingests variants from a YBrowse VCF file. + * + * @param vcfFile The VCF file to ingest. + * @param liftoverChains Map of reference genome name to chain file for liftover (e.g., "GRCh37" -> chainFile). + * @return A Future containing the number of variants ingested. + */ + def ingestVcf(vcfFile: File, liftoverChains: Map[String, File] = Map.empty): Future[Int] = { + val reader = new VCFFileReader(vcfFile, false) + val iterator = reader.iterator().asScala + + // Initialize LiftOver instances + val liftovers = liftoverChains.map { case (genome, file) => + genome -> new LiftOver(file) + } + + // Pre-fetch contigs for relevant genomes (hg38 + targets) + // We assume input is hg38. We also need target genomes. + // For simplicity, we'll fetch common names and filter by genome in memory or separate queries. + // But since we don't know the exact "reference_genome" strings in DB, we'll fetch by common name "chrY" etc. + // and let the caching logic handle it. + + // We'll process in batches to avoid OOM and DB overload + val batchSize = 1000 + + // We need a way to map (CommonName, ReferenceGenome) -> ContigID + // We'll build this cache lazily or pre-fetch if we know the contigs. + // YBrowse is mostly Y-DNA, so "chrY". + + processBatches(iterator, batchSize, liftovers) + } + + private def processBatches( + iterator: Iterator[VariantContext], + batchSize: Int, + liftovers: Map[String, LiftOver] + ): Future[Int] = { + + // Recursive or fold based batch processing + // Since it's Future-based, we'll use recursion or a foldLeft with Future. + + def processNextBatch(accumulatedCount: Int): Future[Int] = { + if (!iterator.hasNext) { + Future.successful(accumulatedCount) + } else { + val batch = iterator.take(batchSize).toSeq + processBatch(batch, liftovers).flatMap { count => + processNextBatch(accumulatedCount + count) + } + } + } + + processNextBatch(0) + } + + private def processBatch(batch: Seq[VariantContext], liftovers: Map[String, LiftOver]): Future[Int] = { + // 1. Collect all contig names from batch + val contigNames = batch.map(_.getContig).distinct + // 2. Resolve Contig IDs for hg38 (source) and targets + // We assume "hg38" is the source genome name in DB. + // And liftovers keys are target genome names. + + val allGenomes = Set("hg38", "GRCh38") ++ liftovers.keys + + 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.genbankContigId.get + }.toMap + + val variantsToSave = batch.flatMap { vc => + // Normalize + val normalizedVc = normalizeVariant(vc) + + // Create hg38 variant + val hg38Variants = createVariantsForContext(normalizedVc, "hg38", 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 + // Note: Liftover only gives coordinates. Alleles might change (strand flip). + // HTSJDK LiftOver doesn't automatically handle allele flipping for VCF records generically without reference. + // We'll assume positive strand or handle it if we had reference. + // For now, we keep alleles as is, assuming forward strand mapping (common for Y). + + // We need to map the contig name. liftOver returns interval with new contig name. + val liftedVc = new htsjdk.variant.variantcontext.VariantContextBuilder(normalizedVc) + .chr(lifted.getContig) + .start(lifted.getStart) + .stop(lifted.getEnd) + .make() + + createVariantsForContext(liftedVc, targetGenome, contigMap) + } else { + Seq.empty + } + } + + hg38Variants ++ 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 + + Variant( + genbankContigId = contigId, + position = vc.getStart, + referenceAllele = vc.getReference.getDisplayString, + alternateAllele = alt.getDisplayString, + variantType = vc.getType.toString, + rsId = rsId, + commonName = commonName + ) + }.toSeq + case None => + // Logger.warn(s"Contig not found for ${vc.getContig} in $genome") + Seq.empty + } + } + + private def normalizeVariant(vc: VariantContext): VariantContext = { + // Basic trimming of common flanking bases + // This is a simplified normalization. + // Ideally we'd use a reference sequence. + vc + } +} 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/routes b/conf/routes index b16635b..9cca10d 100644 --- a/conf/routes +++ b/conf/routes @@ -121,6 +121,9 @@ 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) From d0ba0dfb81c6a86d31cc59470fb9c3188e525c23 Mon Sep 17 00:00:00 2001 From: jkane Date: Wed, 10 Dec 2025 05:54:37 -0600 Subject: [PATCH 02/16] feat(genomics): Add `GenomicsConfig` for reference genome configuration and integrate with ingestion service - Introduced `GenomicsConfig` for managing genomics-related settings, including reference genome aliases, supported references, FASTA paths, and liftover chains. - Extended `YBrowseVariantIngestionService` to utilize `GenomicsConfig` for canonical genome name resolution, reference FASTA loading, and liftover chain management. - Added normalization logic for variants, including left-alignment and allele trimming using reference sequences. --- app/config/GenomicsConfig.scala | 45 ++++ .../YBrowseVariantIngestionService.scala | 194 +++++++++++++----- conf/application.conf | 31 +++ 3 files changed, 219 insertions(+), 51 deletions(-) create mode 100644 app/config/GenomicsConfig.scala diff --git a/app/config/GenomicsConfig.scala b/app/config/GenomicsConfig.scala new file mode 100644 index 0000000..0d2d6bd --- /dev/null +++ b/app/config/GenomicsConfig.scala @@ -0,0 +1,45 @@ +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) + } + + /** + * 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/services/genomics/YBrowseVariantIngestionService.scala b/app/services/genomics/YBrowseVariantIngestionService.scala index 6310d65..62f5516 100644 --- a/app/services/genomics/YBrowseVariantIngestionService.scala +++ b/app/services/genomics/YBrowseVariantIngestionService.scala @@ -1,6 +1,8 @@ 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 @@ -16,58 +18,74 @@ import scala.jdk.CollectionConverters.* @Singleton class YBrowseVariantIngestionService @Inject()( variantRepository: VariantRepository, - genbankContigRepository: GenbankContigRepository + 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 liftoverChains Map of reference genome name to chain file for liftover (e.g., "GRCh37" -> chainFile). + * @param sourceGenome The reference genome of the input VCF (default: "GRCh38"). * @return A Future containing the number of variants ingested. */ - def ingestVcf(vcfFile: File, liftoverChains: Map[String, File] = Map.empty): Future[Int] = { + def ingestVcf(vcfFile: File, sourceGenome: String = "GRCh38"): Future[Int] = { val reader = new VCFFileReader(vcfFile, false) val iterator = reader.iterator().asScala - // Initialize LiftOver instances - val liftovers = liftoverChains.map { case (genome, file) => - genome -> new LiftOver(file) - } + // Resolve canonical source genome name + val canonicalSource = genomicsConfig.resolveReferenceName(sourceGenome) - // Pre-fetch contigs for relevant genomes (hg38 + targets) - // We assume input is hg38. We also need target genomes. - // For simplicity, we'll fetch common names and filter by genome in memory or separate queries. - // But since we don't know the exact "reference_genome" strings in DB, we'll fetch by common name "chrY" etc. - // and let the caching logic handle it. + // 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 - // We'll process in batches to avoid OOM and DB overload val batchSize = 1000 - // We need a way to map (CommonName, ReferenceGenome) -> ContigID - // We'll build this cache lazily or pre-fetch if we know the contigs. - // YBrowse is mostly Y-DNA, so "chrY". - - processBatches(iterator, batchSize, liftovers) + processBatches(iterator, batchSize, liftovers, canonicalSource) } private def processBatches( iterator: Iterator[VariantContext], batchSize: Int, - liftovers: Map[String, LiftOver] + liftovers: Map[String, LiftOver], + sourceGenome: String ): Future[Int] = { - // Recursive or fold based batch processing - // Since it's Future-based, we'll use recursion or a foldLeft with Future. - def processNextBatch(accumulatedCount: Int): Future[Int] = { if (!iterator.hasNext) { Future.successful(accumulatedCount) } else { val batch = iterator.take(batchSize).toSeq - processBatch(batch, liftovers).flatMap { count => + processBatch(batch, liftovers, sourceGenome).flatMap { count => processNextBatch(accumulatedCount + count) } } @@ -76,14 +94,10 @@ class YBrowseVariantIngestionService @Inject()( processNextBatch(0) } - private def processBatch(batch: Seq[VariantContext], liftovers: Map[String, LiftOver]): Future[Int] = { + 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 - // We assume "hg38" is the source genome name in DB. - // And liftovers keys are target genome names. - - val allGenomes = Set("hg38", "GRCh38") ++ liftovers.keys genbankContigRepository.findByCommonNames(contigNames).flatMap { contigs => // Map: (CommonName, Genome) -> ContigID @@ -91,15 +105,12 @@ class YBrowseVariantIngestionService @Inject()( for { cn <- c.commonName rg <- c.referenceGenome - } yield (cn, rg) -> c.genbankContigId.get + } yield (cn, rg) -> c.id.get }.toMap val variantsToSave = batch.flatMap { vc => - // Normalize - val normalizedVc = normalizeVariant(vc) - - // Create hg38 variant - val hg38Variants = createVariantsForContext(normalizedVc, "hg38", contigMap) + // Create source variants (normalization happens in createVariantsForContext) + val sourceVariants = createVariantsForContext(vc, sourceGenome, contigMap) // Create lifted variants val liftedVariants = liftovers.flatMap { case (targetGenome, liftOver) => @@ -107,25 +118,19 @@ class YBrowseVariantIngestionService @Inject()( val lifted = liftOver.liftOver(interval) if (lifted != null) { // Create variant context with new position - // Note: Liftover only gives coordinates. Alleles might change (strand flip). - // HTSJDK LiftOver doesn't automatically handle allele flipping for VCF records generically without reference. - // We'll assume positive strand or handle it if we had reference. - // For now, we keep alleles as is, assuming forward strand mapping (common for Y). - - // We need to map the contig name. liftOver returns interval with new contig name. - val liftedVc = new htsjdk.variant.variantcontext.VariantContextBuilder(normalizedVc) + 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 } } - hg38Variants ++ liftedVariants + sourceVariants ++ liftedVariants } variantRepository.findOrCreateVariantsBatch(variantsToSave).map(_.size) @@ -150,11 +155,21 @@ class YBrowseVariantIngestionService @Inject()( // 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 = vc.getStart, - referenceAllele = vc.getReference.getDisplayString, - alternateAllele = alt.getDisplayString, + position = normPos, + referenceAllele = normRef, + alternateAllele = normAlt, variantType = vc.getType.toString, rsId = rsId, commonName = commonName @@ -166,10 +181,87 @@ class YBrowseVariantIngestionService @Inject()( } } - private def normalizeVariant(vc: VariantContext): VariantContext = { - // Basic trimming of common flanking bases - // This is a simplified normalization. - // Ideally we'd use a reference sequence. - vc + /** + * 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 + } + } } } diff --git a/conf/application.conf b/conf/application.conf index 66ee2f9..14d9083 100644 --- a/conf/application.conf +++ b/conf/application.conf @@ -125,4 +125,35 @@ aws { biosample.hash.salt = "your-biosample-salt" biosample.hash.salt = ${?BIOSAMPLE_HASH_SALT} +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" + } + } +} + From e2fb576a5a97727636d99bfec2c1729c7ea92c8e Mon Sep 17 00:00:00 2001 From: jkane Date: Wed, 10 Dec 2025 06:07:23 -0600 Subject: [PATCH 03/16] feat(genomics): Add YBrowse variant update system with admin API and scheduled ingestion - Introduced `GenomicsAdminController` for triggering on-demand YBrowse variant updates via an admin-only API. - Added `YBrowseVariantUpdateActor` for downloading and ingesting Y-DNA SNP data from YBrowse. - Scheduled periodic ingestion of YBrowse VCF files using Quartz. - Updated `GenomicsConfig` with YBrowse configuration parameters (VCF URL, storage path). --- app/actors/YBrowseVariantUpdateActor.scala | 122 ++++++++++++++++++ app/config/GenomicsConfig.scala | 8 +- app/controllers/GenomicsAdminController.scala | 77 +++++++++++ app/modules/ApplicationModule.scala | 3 +- app/modules/Scheduler.scala | 13 +- conf/application.conf | 19 ++- conf/routes | 3 + 7 files changed, 240 insertions(+), 5 deletions(-) create mode 100644 app/actors/YBrowseVariantUpdateActor.scala create mode 100644 app/controllers/GenomicsAdminController.scala 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/GenomicsConfig.scala b/app/config/GenomicsConfig.scala index 0d2d6bd..41dfa19 100644 --- a/app/config/GenomicsConfig.scala +++ b/app/config/GenomicsConfig.scala @@ -14,13 +14,17 @@ 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. * @@ -32,7 +36,7 @@ class GenomicsConfig @Inject()(config: Configuration) { 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. * 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/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/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/conf/application.conf b/conf/application.conf index 14d9083..63588c2 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" + } } } @@ -129,7 +136,7 @@ genomics { references { # Canonical names for supported linear reference builds supported = ["GRCh37", "GRCh38", "hs1"] - + # Aliases to map common names to canonical names aliases { "hg19" = "GRCh37" @@ -154,6 +161,16 @@ genomics { "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/routes b/conf/routes index 9cca10d..e9d7d49 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 From ac7144b7029b0f9f8c51925e653409491d1a00e9 Mon Sep 17 00:00:00 2001 From: jkane Date: Wed, 10 Dec 2025 07:00:48 -0600 Subject: [PATCH 04/16] feat(curation): Add TreeCurator role, permissions, audit logging, and curator-centric workflows - Introduced `TreeCurator` role with specific permissions for haplogroup and variant curation. - Added comprehensive curator audit logging, including schemas, tables, and services for create/update/delete actions. - Implemented `AuditLogEntry` model and `AuditLogTable` for audit trail tracking. - Developed `CuratorController` with forms and workflows for creating haplogroups and variants. - Enhanced UI with haplogroup and variant creation views. --- app/actions/AuthenticatedAction.scala | 20 +- app/controllers/CuratorController.scala | 437 ++++++++++++++++++ app/models/dal/DatabaseSchema.scala | 5 + app/models/dal/curator/AuditLogTable.scala | 37 ++ app/models/domain/curator/AuditLogEntry.scala | 65 +++ app/repositories/CuratorAuditRepository.scala | 85 ++++ .../HaplogroupCoreRepository.scala | 213 ++++++++- app/repositories/VariantRepository.scala | 77 +++ app/services/CuratorAuditService.scala | 197 ++++++++ .../curator/audit/historyPanel.scala.html | 87 ++++ app/views/curator/dashboard.scala.html | 77 +++ .../curator/haplogroups/createForm.scala.html | 115 +++++ .../haplogroups/detailPanel.scala.html | 116 +++++ .../curator/haplogroups/editForm.scala.html | 109 +++++ app/views/curator/haplogroups/list.scala.html | 102 ++++ .../haplogroups/listFragment.scala.html | 78 ++++ .../curator/variants/createForm.scala.html | 132 ++++++ .../curator/variants/detailPanel.scala.html | 80 ++++ .../curator/variants/editForm.scala.html | 116 +++++ app/views/curator/variants/list.scala.html | 90 ++++ .../curator/variants/listFragment.scala.html | 83 ++++ conf/evolutions/default/46.sql | 72 +++ conf/routes | 26 ++ 23 files changed, 2414 insertions(+), 5 deletions(-) create mode 100644 app/controllers/CuratorController.scala create mode 100644 app/models/dal/curator/AuditLogTable.scala create mode 100644 app/models/domain/curator/AuditLogEntry.scala create mode 100644 app/repositories/CuratorAuditRepository.scala create mode 100644 app/services/CuratorAuditService.scala create mode 100644 app/views/curator/audit/historyPanel.scala.html create mode 100644 app/views/curator/dashboard.scala.html create mode 100644 app/views/curator/haplogroups/createForm.scala.html create mode 100644 app/views/curator/haplogroups/detailPanel.scala.html create mode 100644 app/views/curator/haplogroups/editForm.scala.html create mode 100644 app/views/curator/haplogroups/list.scala.html create mode 100644 app/views/curator/haplogroups/listFragment.scala.html create mode 100644 app/views/curator/variants/createForm.scala.html create mode 100644 app/views/curator/variants/detailPanel.scala.html create mode 100644 app/views/curator/variants/editForm.scala.html create mode 100644 app/views/curator/variants/list.scala.html create mode 100644 app/views/curator/variants/listFragment.scala.html create mode 100644 conf/evolutions/default/46.sql 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/controllers/CuratorController.scala b/app/controllers/CuratorController.scala new file mode 100644 index 0000000..e66ad62 --- /dev/null +++ b/app/controllers/CuratorController.scala @@ -0,0 +1,437 @@ +package controllers + +import actions.{AuthenticatedAction, AuthenticatedRequest, PermissionAction} +import jakarta.inject.{Inject, Singleton} +import models.HaplogroupType +import models.dal.domain.genomics.Variant +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.{HaplogroupCoreRepository, VariantRepository} +import services.CuratorAuditService + +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 +) + +case class VariantFormData( + genbankContigId: Int, + position: Int, + referenceAllele: String, + alternateAllele: String, + variantType: String, + rsId: Option[String], + commonName: Option[String] +) + +@Singleton +class CuratorController @Inject()( + val controllerComponents: ControllerComponents, + authenticatedAction: AuthenticatedAction, + permissionAction: PermissionAction, + haplogroupRepository: HaplogroupCoreRepository, + variantRepository: VariantRepository, + auditService: CuratorAuditService +)(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) + )(HaplogroupFormData.apply)(h => Some((h.name, h.lineage, h.description, h.haplogroupType, h.source, h.confidenceLevel))) + ) + + 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))) + ) + + // === 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) + history <- auditService.getHaplogroupHistory(id) + } yield { + haplogroupOpt match { + case Some(haplogroup) => + Ok(views.html.curator.haplogroups.detailPanel(haplogroup, parentOpt, children, history)) + case None => + NotFound("Haplogroup not found") + } + } + } + + def createHaplogroupForm: Action[AnyContent] = + withPermission("haplogroup.create").async { implicit request => + Future.successful(Ok(views.html.curator.haplogroups.createForm(haplogroupForm))) + } + + def createHaplogroup: Action[AnyContent] = + withPermission("haplogroup.create").async { implicit request => + haplogroupForm.bindFromRequest().fold( + formWithErrors => { + Future.successful(BadRequest(views.html.curator.haplogroups.createForm(formWithErrors))) + }, + data => { + val haplogroup = Haplogroup( + id = None, + name = data.name, + lineage = data.lineage, + description = data.description, + haplogroupType = HaplogroupType.fromString(data.haplogroupType).get, + revisionId = 1, + source = data.source, + confidenceLevel = data.confidenceLevel, + validFrom = LocalDateTime.now(), + validUntil = None + ) + + for { + newId <- haplogroupRepository.create(haplogroup) + createdHaplogroup = haplogroup.copy(id = Some(newId)) + _ <- auditService.logHaplogroupCreate(request.user.id.get, createdHaplogroup, Some("Created via curator interface")) + } yield { + Redirect(routes.CuratorController.listHaplogroups(None, None, 1, 20)) + .flashing("success" -> s"Haplogroup '${data.name}' created successfully") + } + } + ) + } + + 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 + ) + 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 + ) + + 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 => + val offset = (page - 1) * pageSize + + for { + variants <- query match { + case Some(q) if q.nonEmpty => variantRepository.search(q, pageSize, offset) + case _ => variantRepository.search("", pageSize, offset) + } + totalCount <- variantRepository.count(query.filter(_.nonEmpty)) + } yield { + val totalPages = Math.max(1, (totalCount + pageSize - 1) / pageSize) + Ok(views.html.curator.variants.list(variants, query, page, totalPages, pageSize)) + } + } + + def variantsFragment(query: Option[String], page: Int, pageSize: Int): Action[AnyContent] = + withPermission("variant.view").async { implicit request => + val offset = (page - 1) * pageSize + + for { + variants <- query match { + case Some(q) if q.nonEmpty => variantRepository.search(q, pageSize, offset) + case _ => variantRepository.search("", pageSize, offset) + } + totalCount <- variantRepository.count(query.filter(_.nonEmpty)) + } yield { + val totalPages = Math.max(1, (totalCount + pageSize - 1) / pageSize) + Ok(views.html.curator.variants.listFragment(variants, query, page, totalPages, pageSize)) + } + } + + def variantDetailPanel(id: Int): Action[AnyContent] = + withPermission("variant.view").async { implicit request => + for { + variantOpt <- variantRepository.findById(id) + history <- auditService.getVariantHistory(id) + } yield { + variantOpt match { + case Some(variant) => + Ok(views.html.curator.variants.detailPanel(variant, history)) + case None => + NotFound("Variant not found") + } + } + } + + def createVariantForm: Action[AnyContent] = + withPermission("variant.create").async { implicit request => + Future.successful(Ok(views.html.curator.variants.createForm(variantForm))) + } + + def createVariant: Action[AnyContent] = + withPermission("variant.create").async { implicit request => + variantForm.bindFromRequest().fold( + formWithErrors => { + Future.successful(BadRequest(views.html.curator.variants.createForm(formWithErrors))) + }, + 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 { + newId <- variantRepository.createVariant(variant) + createdVariant = variant.copy(variantId = Some(newId)) + _ <- auditService.logVariantCreate(request.user.id.get, createdVariant, Some("Created via curator interface")) + } yield { + Redirect(routes.CuratorController.listVariants(None, 1, 20)) + .flashing("success" -> s"Variant created successfully") + } + } + ) + } + + 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 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)) + } + } +} diff --git a/app/models/dal/DatabaseSchema.scala b/app/models/dal/DatabaseSchema.scala index f7d7fa0..8c09bce 100644 --- a/app/models/dal/DatabaseSchema.scala +++ b/app/models/dal/DatabaseSchema.scala @@ -148,4 +148,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/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/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/HaplogroupCoreRepository.scala b/app/repositories/HaplogroupCoreRepository.scala index 57c8766..04f7ad8 100644 --- a/app/repositories/HaplogroupCoreRepository.scala +++ b/app/repositories/HaplogroupCoreRepository.scala @@ -37,19 +37,82 @@ 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] } 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 +166,152 @@ 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) + } } diff --git a/app/repositories/VariantRepository.scala b/app/repositories/VariantRepository.scala index b688775..f8f07df 100644 --- a/app/repositories/VariantRepository.scala +++ b/app/repositories/VariantRepository.scala @@ -82,6 +82,33 @@ trait VariantRepository { * @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]] + + /** + * Search variants by name with pagination. + */ + def search(query: String, limit: Int, offset: Int): Future[Seq[Variant]] + + /** + * 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] } class VariantRepositoryImpl @Inject()( @@ -188,4 +215,54 @@ class VariantRepositoryImpl @Inject()( // Use runTransactionally from BaseRepository runTransactionally(combinedAction) } + + // === Curator CRUD Methods Implementation === + + override def findById(id: Int): Future[Option[Variant]] = { + db.run(variants.filter(_.variantId === id).result.headOption) + } + + 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 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) + } + + 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) + } } diff --git a/app/services/CuratorAuditService.scala b/app/services/CuratorAuditService.scala new file mode 100644 index 0000000..0fdb616 --- /dev/null +++ b/app/services/CuratorAuditService.scala @@ -0,0 +1,197 @@ +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 +import play.api.Logging +import play.api.libs.json.* +import repositories.CuratorAuditRepository + +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 +)(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) + } +} 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..9683251 --- /dev/null +++ b/app/views/curator/haplogroups/createForm.scala.html @@ -0,0 +1,115 @@ +@import org.webjars.play.WebJarsUtil +@import controllers.HaplogroupFormData +@(form: Form[HaplogroupFormData])(implicit request: RequestHeader, messages: Messages, webJarsUtil: WebJarsUtil) + +@main("Create Haplogroup") { +
+
+
+ + +
+
+
Create Haplogroup
+
+
+ @helper.form(controllers.routes.CuratorController.createHaplogroup) { + @helper.CSRF.formField + +
+ + + @form.error("name").map { error => +
@error.message
+ } +
+ +
+ + + @form.error("haplogroupType").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..46e590a --- /dev/null +++ b/app/views/curator/haplogroups/detailPanel.scala.html @@ -0,0 +1,116 @@ +@import models.domain.haplogroups.Haplogroup +@import models.domain.curator.AuditLogEntry +@(haplogroup: Haplogroup, parentOpt: Option[Haplogroup], children: Seq[Haplogroup], history: Seq[AuditLogEntry])(implicit request: RequestHeader) + +
+
+
@haplogroup.name
+ + @haplogroup.haplogroupType + +
+
+
+
Lineage
+
@haplogroup.lineage.getOrElse("-")
+ +
Description
+
@haplogroup.description.getOrElse("-")
+ +
Source
+
@haplogroup.source
+ +
Confidence
+
@haplogroup.confidenceLevel
+ +
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 + } + } +
+
+ +
+ +
+ + Edit + + +
+ + @if(history.nonEmpty) { +
+
Recent History
+
    + @for(entry <- history.take(5)) { +
  • + @entry.action + @entry.createdAt.toLocalDate + @entry.comment.map { c =>
    @c } +
  • + } +
+ @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..6d27244 --- /dev/null +++ b/app/views/curator/haplogroups/editForm.scala.html @@ -0,0 +1,109 @@ +@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
+ } +
+ +
+ + + + Type cannot be changed after creation +
+ +
+ + +
+ +
+ + +
+ +
+
+ + + @form.error("source").map { error => +
@error.message
+ } +
+
+ + + @form.error("confidenceLevel").map { error => +
@error.message
+ } +
+
+ +
+ + + 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/variants/createForm.scala.html b/app/views/curator/variants/createForm.scala.html new file mode 100644 index 0000000..9fe4249 --- /dev/null +++ b/app/views/curator/variants/createForm.scala.html @@ -0,0 +1,132 @@ +@import org.webjars.play.WebJarsUtil +@import controllers.VariantFormData +@(form: Form[VariantFormData])(implicit request: RequestHeader, messages: Messages, webJarsUtil: WebJarsUtil) + +@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
+ } +
+
+ +
+
+ + + @form.error("referenceAllele").map { error => +
@error.message
+ } +
+
+ + + @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..92ce7d8 --- /dev/null +++ b/app/views/curator/variants/detailPanel.scala.html @@ -0,0 +1,80 @@ +@import models.dal.domain.genomics.Variant +@import models.domain.curator.AuditLogEntry +@(variant: Variant, history: Seq[AuditLogEntry])(implicit request: RequestHeader) + +
+
+
@variant.rsId.orElse(variant.commonName).getOrElse(s"Variant ${variant.variantId.get}")
+ @variant.variantType +
+
+
+
rsId
+
@variant.rsId.getOrElse("-")
+ +
Common Name
+
@variant.commonName.getOrElse("-")
+ +
Contig
+
@variant.genbankContigId
+ +
Position
+
@variant.position
+ +
Reference
+
@variant.referenceAllele
+ +
Alternate
+
@variant.alternateAllele
+ +
Type
+
@variant.variantType
+
+ +
+ +
+ + Edit + + +
+ + @if(history.nonEmpty) { +
+
Recent History
+
    + @for(entry <- history.take(5)) { +
  • + @entry.action + @entry.createdAt.toLocalDate + @entry.comment.map { c =>
    @c } +
  • + } +
+ @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/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/list.scala.html b/app/views/curator/variants/list.scala.html new file mode 100644 index 0000000..1b3131d --- /dev/null +++ b/app/views/curator/variants/list.scala.html @@ -0,0 +1,90 @@ +@import org.webjars.play.WebJarsUtil +@import models.dal.domain.genomics.Variant +@(variants: Seq[Variant], 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(variants, 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..f07e7df --- /dev/null +++ b/app/views/curator/variants/listFragment.scala.html @@ -0,0 +1,83 @@ +@import models.dal.domain.genomics.Variant +@(variants: Seq[Variant], query: Option[String], currentPage: Int, totalPages: Int, pageSize: Int)(implicit request: RequestHeader) + +@if(variants.isEmpty) { +
No variants found matching your criteria.
+} else { +
+ + + + + + + + + + + + @for(v <- variants) { + + + + + + + + } + +
rsId / NamePositionRef/AltType
+ @v.rsId.orElse(v.commonName).getOrElse(s"ID: ${v.variantId.get}") + @v.commonName.filter(n => v.rsId.isDefined).map { name => +
@name + } +
Chr@v.genbankContigId:@v.position + @v.referenceAllele/@v.alternateAllele + + @v.variantType + + + + +
+
+ + @if(totalPages > 1) { + + } +} 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/routes b/conf/routes index e9d7d49..bfccfe7 100644 --- a/conf/routes +++ b/conf/routes @@ -132,6 +132,32 @@ GET /admin/publication-candidates 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/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 - 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) + +# 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() From ef7c38e6582f1c1f7beba354c4ad3b60d2ece33b Mon Sep 17 00:00:00 2001 From: jkane Date: Wed, 10 Dec 2025 07:23:35 -0600 Subject: [PATCH 05/16] feat(genomics): Add `VariantWithContig` model and integrate contig data into variant workflows - Introduced `VariantWithContig` to combine variant and GenbankContig data for improved context. - Extended `VariantRepository` with `findByIdWithContig` and `searchWithContig` methods. - Updated curator interface to display contig and reference genome details. - Refactored variant detail and list views to use `VariantWithContig`. --- app/controllers/CuratorController.scala | 15 +++---- .../domain/genomics/VariantWithContig.scala | 27 ++++++++++++ app/repositories/VariantRepository.scala | 36 +++++++++++++++- .../curator/variants/detailPanel.scala.html | 41 +++++++++++-------- app/views/curator/variants/list.scala.html | 4 +- .../curator/variants/listFragment.scala.html | 24 ++++++----- 6 files changed, 108 insertions(+), 39 deletions(-) create mode 100644 app/models/domain/genomics/VariantWithContig.scala diff --git a/app/controllers/CuratorController.scala b/app/controllers/CuratorController.scala index e66ad62..433446e 100644 --- a/app/controllers/CuratorController.scala +++ b/app/controllers/CuratorController.scala @@ -4,6 +4,7 @@ import actions.{AuthenticatedAction, AuthenticatedRequest, PermissionAction} import jakarta.inject.{Inject, Singleton} import models.HaplogroupType import models.dal.domain.genomics.Variant +import models.domain.genomics.VariantWithContig import models.domain.haplogroups.Haplogroup import org.webjars.play.WebJarsUtil import play.api.Logging @@ -265,8 +266,8 @@ class CuratorController @Inject()( for { variants <- query match { - case Some(q) if q.nonEmpty => variantRepository.search(q, pageSize, offset) - case _ => variantRepository.search("", pageSize, offset) + case Some(q) if q.nonEmpty => variantRepository.searchWithContig(q, pageSize, offset) + case _ => variantRepository.searchWithContig("", pageSize, offset) } totalCount <- variantRepository.count(query.filter(_.nonEmpty)) } yield { @@ -281,8 +282,8 @@ class CuratorController @Inject()( for { variants <- query match { - case Some(q) if q.nonEmpty => variantRepository.search(q, pageSize, offset) - case _ => variantRepository.search("", pageSize, offset) + case Some(q) if q.nonEmpty => variantRepository.searchWithContig(q, pageSize, offset) + case _ => variantRepository.searchWithContig("", pageSize, offset) } totalCount <- variantRepository.count(query.filter(_.nonEmpty)) } yield { @@ -294,12 +295,12 @@ class CuratorController @Inject()( def variantDetailPanel(id: Int): Action[AnyContent] = withPermission("variant.view").async { implicit request => for { - variantOpt <- variantRepository.findById(id) + variantOpt <- variantRepository.findByIdWithContig(id) history <- auditService.getVariantHistory(id) } yield { variantOpt match { - case Some(variant) => - Ok(views.html.curator.variants.detailPanel(variant, history)) + case Some(variantWithContig) => + Ok(views.html.curator.variants.detailPanel(variantWithContig, history)) case None => NotFound("Variant not found") } 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/repositories/VariantRepository.scala b/app/repositories/VariantRepository.scala index f8f07df..f3e616d 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, VariantWithContig} import org.postgresql.util.PSQLException import play.api.db.slick.DatabaseConfigProvider @@ -90,11 +91,21 @@ trait VariantRepository { */ 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. */ @@ -117,7 +128,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, @@ -222,6 +233,15 @@ class VariantRepositoryImpl @Inject()( 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 => @@ -236,6 +256,20 @@ class VariantRepositoryImpl @Inject()( 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) => diff --git a/app/views/curator/variants/detailPanel.scala.html b/app/views/curator/variants/detailPanel.scala.html index 92ce7d8..a49aa9a 100644 --- a/app/views/curator/variants/detailPanel.scala.html +++ b/app/views/curator/variants/detailPanel.scala.html @@ -1,46 +1,51 @@ -@import models.dal.domain.genomics.Variant +@import models.domain.genomics.VariantWithContig @import models.domain.curator.AuditLogEntry -@(variant: Variant, history: Seq[AuditLogEntry])(implicit request: RequestHeader) +@(vwc: VariantWithContig, history: Seq[AuditLogEntry])(implicit request: RequestHeader)
-
@variant.rsId.orElse(variant.commonName).getOrElse(s"Variant ${variant.variantId.get}")
- @variant.variantType +
@vwc.variant.rsId.orElse(vwc.variant.commonName).getOrElse(s"Variant ${vwc.variant.variantId.get}")
+ @vwc.variant.variantType
rsId
-
@variant.rsId.getOrElse("-")
+
@vwc.variant.rsId.getOrElse("-")
Common Name
-
@variant.commonName.getOrElse("-")
- -
Contig
-
@variant.genbankContigId
+
@vwc.variant.commonName.getOrElse("-")
Position
-
@variant.position
+
@vwc.formattedPosition
+ +
Reference Build
+
+ @vwc.contig.referenceGenome.getOrElse("Unknown") +
+ +
Accession
+
@vwc.contig.accession
-
Reference
-
@variant.referenceAllele
+
Ancestral
+
@vwc.variant.referenceAllele
-
Alternate
-
@variant.alternateAllele
+
Derived
+
@vwc.variant.alternateAllele
Type
-
@variant.variantType
+
@vwc.variant.variantType

- Edit @@ -61,7 +66,7 @@
Recent History
@if(history.size > 5) { View all history... diff --git a/app/views/curator/variants/list.scala.html b/app/views/curator/variants/list.scala.html index 1b3131d..77f4ca2 100644 --- a/app/views/curator/variants/list.scala.html +++ b/app/views/curator/variants/list.scala.html @@ -1,6 +1,6 @@ @import org.webjars.play.WebJarsUtil -@import models.dal.domain.genomics.Variant -@(variants: Seq[Variant], query: Option[String], currentPage: Int, totalPages: Int, pageSize: Int)(implicit request: RequestHeader, messages: Messages, webJarsUtil: WebJarsUtil) +@import models.domain.genomics.VariantWithContig +@(variants: Seq[VariantWithContig], query: Option[String], currentPage: Int, totalPages: Int, pageSize: Int)(implicit request: RequestHeader, messages: Messages, webJarsUtil: WebJarsUtil) @main("Curator - Variants") { + +@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 + } +} From 901927cd9ac136a8d5fbd4c4e01ebca2409e8493 Mon Sep 17 00:00:00 2001 From: jkane Date: Wed, 10 Dec 2025 14:46:03 -0600 Subject: [PATCH 15/16] feat(tree): Add branch age estimates (Formed/TMRCA) to haplogroup tree - Added SQL migrations to introduce branch age columns (`formed_ybp`, `tmrca_ybp`, and confidence intervals) in `haplogroup` table. - Updated models (`Haplogroup`, `TreeNodeDTO`, and `TreeViewModel`) to support branch age data. - Enhanced curator forms and views to allow editing and displaying branch age estimates, with input fields for CI ranges and source attribution. - Integrated feature flag (`showBranchAgeEstimates`) to control visibility in the tree UI. - Adjusted tree layout to account for larger node dimensions with age data shown. --- app/config/FeatureFlags.scala | 20 +++ app/controllers/CuratorController.scala | 38 +++- app/controllers/TreeController.scala | 6 +- app/models/api/TreeDTO.scala | 11 +- .../domain/haplogroups/HaplogroupsTable.scala | 20 ++- .../domain/haplogroups/Haplogroup.scala | 60 ++++++- app/models/view/TreeViewModels.scala | 16 +- app/services/HaplogroupTreeService.scala | 10 +- app/services/TreeLayoutService.scala | 17 +- .../haplogroups/detailPanel.scala.html | 15 ++ .../curator/haplogroups/editForm.scala.html | 168 +++++++++++++----- app/views/fragments/haplogroup.scala.html | 129 ++++++++++---- conf/application.conf | 10 ++ conf/evolutions/default/48.sql | 32 ++++ 14 files changed, 452 insertions(+), 100 deletions(-) create mode 100644 app/config/FeatureFlags.scala create mode 100644 conf/evolutions/default/48.sql 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/controllers/CuratorController.scala b/app/controllers/CuratorController.scala index 8023965..9882d23 100644 --- a/app/controllers/CuratorController.scala +++ b/app/controllers/CuratorController.scala @@ -25,7 +25,14 @@ case class HaplogroupFormData( description: Option[String], haplogroupType: String, source: String, - confidenceLevel: 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( @@ -87,8 +94,15 @@ class CuratorController @Inject()( "description" -> optional(text(maxLength = 2000)), "haplogroupType" -> nonEmptyText.verifying("Invalid type", t => HaplogroupType.fromString(t).isDefined), "source" -> nonEmptyText(1, 100), - "confidenceLevel" -> nonEmptyText(1, 50) - )(HaplogroupFormData.apply)(h => Some((h.name, h.lineage, h.description, h.haplogroupType, h.source, h.confidenceLevel))) + "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( @@ -332,7 +346,14 @@ class CuratorController @Inject()( description = haplogroup.description, haplogroupType = haplogroup.haplogroupType.toString, source = haplogroup.source, - confidenceLevel = haplogroup.confidenceLevel + 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 => @@ -354,7 +375,14 @@ class CuratorController @Inject()( lineage = data.lineage, description = data.description, source = data.source, - confidenceLevel = data.confidenceLevel + 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 { 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/models/api/TreeDTO.scala b/app/models/api/TreeDTO.scala index 47958ae..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. * 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/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/services/HaplogroupTreeService.scala b/app/services/HaplogroupTreeService.scala index 17b734c..8a3d163 100644 --- a/app/services/HaplogroupTreeService.scala +++ b/app/services/HaplogroupTreeService.scala @@ -133,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 ) } @@ -145,10 +147,12 @@ 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 ) } 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/views/curator/haplogroups/detailPanel.scala.html b/app/views/curator/haplogroups/detailPanel.scala.html index b8f4652..3c0d0e0 100644 --- a/app/views/curator/haplogroups/detailPanel.scala.html +++ b/app/views/curator/haplogroups/detailPanel.scala.html @@ -24,6 +24,21 @@
@haplogroup.name
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
diff --git a/app/views/curator/haplogroups/editForm.scala.html b/app/views/curator/haplogroups/editForm.scala.html index 6d27244..afc0ada 100644 --- a/app/views/curator/haplogroups/editForm.scala.html +++ b/app/views/curator/haplogroups/editForm.scala.html @@ -3,9 +3,9 @@ @(id: Int, form: Form[HaplogroupFormData])(implicit request: RequestHeader, messages: Messages, webJarsUtil: WebJarsUtil) @main("Edit Haplogroup") { -
-
-
+
+
+
+
+
+
+
-
-
Edit Haplogroup
+
+
Edit Haplogroup
-
+
@helper.form(controllers.routes.CuratorController.updateHaplogroup(id)) { @helper.CSRF.formField -
- - - @form.error("name").map { error => -
@error.message
- } -
- -
- - - - Type cannot be changed after creation +
+
+ + + @form.error("name").map { error => +
@error.message
+ } +
+
+ + + +
-
- +
+
-
- - + rows="2">@form("description").value.getOrElse("")
-
-
- +
+
+ Edit Haplogroup
@error.message
}
-
- - @@ -92,11 +96,89 @@
Edit Haplogroup
+
+
Branch Age Estimates (YBP)
+ +
+
+
+
Formed
+
+
+ +
+
+ +
+
+ +
+
+
+
+
+
+
TMRCA
+
+
+ +
+
+ +
+
+ +
+
+
+
+
+ +
+ + +
+
- - + Cancel
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/conf/application.conf b/conf/application.conf index 63588c2..dee143c 100644 --- a/conf/application.conf +++ b/conf/application.conf @@ -132,6 +132,16 @@ 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 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; From 21a9c6cf1f700447ad4b97dff8f63bab202c3eff Mon Sep 17 00:00:00 2001 From: jkane Date: Wed, 10 Dec 2025 14:48:53 -0600 Subject: [PATCH 16/16] feat(curation): Add variant filtering and count display to haplogroup variants panel - Introduced a dynamic input field for filtering variants in the panel. - Added badge to display the total variant count. - Updated UI with live filter functionality and status indicator for visible variants. - Adjusted layout for improved alignment and responsiveness. --- .../haplogroups/variantsPanel.scala.html | 112 +++++++++++++----- 1 file changed, 81 insertions(+), 31 deletions(-) diff --git a/app/views/curator/haplogroups/variantsPanel.scala.html b/app/views/curator/haplogroups/variantsPanel.scala.html index 898d18e..85bd45f 100644 --- a/app/views/curator/haplogroups/variantsPanel.scala.html +++ b/app/views/curator/haplogroups/variantsPanel.scala.html @@ -1,8 +1,11 @@ @import models.domain.genomics.VariantGroup @(haplogroupId: Int, variantGroups: Seq[VariantGroup])(implicit request: RequestHeader) -
- Defining Variants +
+
+ Defining Variants + @variantGroups.size +
-
+
@if(variantGroups.isEmpty) {

No variants associated with this haplogroup.

} else { -
    - @for(group <- variantGroups) { -
  • -
    -
    - @group.displayName - @group.rsId.filter(_ != group.displayName).map { rs => - (@rs) + @defining(s"variants-$haplogroupId") { containerId => +
    + +
    + +
    +
      + @for(group <- variantGroups) { +
    • +
      +
      + @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 +
      } - @group.buildCount build@if(group.buildCount != 1){s}
      - -
    -
    - @for(vwc <- group.variantsSorted) { -
    - @vwc.shortReferenceGenome - @vwc.formattedPosition - @vwc.variant.referenceAllele→@vwc.variant.alternateAllele -
    +
  • + } +
+
+ + + } + + }