diff --git a/backend/src/main/kotlin/org/loculus/backend/api/SeqSetCitations.kt b/backend/src/main/kotlin/org/loculus/backend/api/SeqSetCitations.kt index 1a5470114b..cbe95616a0 100644 --- a/backend/src/main/kotlin/org/loculus/backend/api/SeqSetCitations.kt +++ b/backend/src/main/kotlin/org/loculus/backend/api/SeqSetCitations.kt @@ -97,6 +97,24 @@ data class SeqSetCitationSource(val source: CitationSource, val seqSetDOIs: Set< @Schema(description = "A citation of a SeqSet.") data class SeqSetCitation(val source: CitationSource) +data class SeqSetCitingSequence( + @Schema( + description = "The accession and version of the SeqSet that was cited.", + type = "string", + example = "PP_SS_1.1", + ) + val seqSetAccessionVersion: String, + @Schema( + description = "The accession of the sequence within the cited SeqSet. Can be either versioned or unversioned.", + type = "string", + example = "PP_123456.1", + ) + val sequenceAccession: String, +) + +@Schema(description = "A citation of a sequence.") +data class SequenceCitation(val source: CitationSource, val seqSets: List) + data class ResponseSeqSet(val seqSetId: String, val seqSetVersion: Long) data class CitedBy( diff --git a/backend/src/main/kotlin/org/loculus/backend/config/SecurityConfig.kt b/backend/src/main/kotlin/org/loculus/backend/config/SecurityConfig.kt index a66ef75461..f26d872343 100644 --- a/backend/src/main/kotlin/org/loculus/backend/config/SecurityConfig.kt +++ b/backend/src/main/kotlin/org/loculus/backend/config/SecurityConfig.kt @@ -56,6 +56,7 @@ class SecurityConfig { "/get-seqset", "/get-seqset-records", "/get-seqset-citations", + "/get-sequence-citations", "/get-author", "/*/get-released-data", "/files/get/**", diff --git a/backend/src/main/kotlin/org/loculus/backend/controller/SeqSetCitationsController.kt b/backend/src/main/kotlin/org/loculus/backend/controller/SeqSetCitationsController.kt index b2a61c00f3..563d17b2a7 100644 --- a/backend/src/main/kotlin/org/loculus/backend/controller/SeqSetCitationsController.kt +++ b/backend/src/main/kotlin/org/loculus/backend/controller/SeqSetCitationsController.kt @@ -2,12 +2,14 @@ package org.loculus.backend.controller import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.security.SecurityRequirement +import org.loculus.backend.api.AccessionVersion import org.loculus.backend.api.AuthorProfile import org.loculus.backend.api.CitedBy import org.loculus.backend.api.ResponseSeqSet import org.loculus.backend.api.SeqSet import org.loculus.backend.api.SeqSetCitation import org.loculus.backend.api.SeqSetRecord +import org.loculus.backend.api.SequenceCitation import org.loculus.backend.api.SubmittedSeqSet import org.loculus.backend.api.SubmittedSeqSetRecord import org.loculus.backend.api.SubmittedSeqSetUpdate @@ -109,6 +111,11 @@ class SeqSetCitationsController( fun getSeqSetCitations(@RequestParam seqSetId: String, @RequestParam version: Long): List = seqSetCitationsService.getSeqSetCitations(seqSetId, version) + @Operation(description = "Get sequence citations from publications or other sources") + @GetMapping("/get-sequence-citations") + fun getSequenceCitations(@RequestParam accession: String, @RequestParam version: Long?): List = + seqSetCitationsService.getSequenceCitations(accession, version) + @Operation(description = "Get an author") @GetMapping("/get-author") fun getAuthor(@RequestParam username: String): AuthorProfile { diff --git a/backend/src/main/kotlin/org/loculus/backend/service/seqsetcitations/SeqSetCitationsDatabaseService.kt b/backend/src/main/kotlin/org/loculus/backend/service/seqsetcitations/SeqSetCitationsDatabaseService.kt index cb54e87880..570951c94e 100644 --- a/backend/src/main/kotlin/org/loculus/backend/service/seqsetcitations/SeqSetCitationsDatabaseService.kt +++ b/backend/src/main/kotlin/org/loculus/backend/service/seqsetcitations/SeqSetCitationsDatabaseService.kt @@ -7,8 +7,12 @@ import kotlinx.datetime.toLocalDateTime import mu.KotlinLogging import org.jetbrains.exposed.sql.Database import org.jetbrains.exposed.sql.JoinType +import org.jetbrains.exposed.sql.LikePattern +import org.jetbrains.exposed.sql.Op import org.jetbrains.exposed.sql.SortOrder import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq +import org.jetbrains.exposed.sql.SqlExpressionBuilder.inList +import org.jetbrains.exposed.sql.SqlExpressionBuilder.like import org.jetbrains.exposed.sql.alias import org.jetbrains.exposed.sql.and import org.jetbrains.exposed.sql.andWhere @@ -17,6 +21,7 @@ import org.jetbrains.exposed.sql.batchUpsert import org.jetbrains.exposed.sql.deleteWhere import org.jetbrains.exposed.sql.insert import org.jetbrains.exposed.sql.max +import org.jetbrains.exposed.sql.or import org.jetbrains.exposed.sql.selectAll import org.jetbrains.exposed.sql.update import org.keycloak.representations.idm.UserRepresentation @@ -30,7 +35,9 @@ import org.loculus.backend.api.SeqSet import org.loculus.backend.api.SeqSetCitation import org.loculus.backend.api.SeqSetCitationSource import org.loculus.backend.api.SeqSetCitationsConstants +import org.loculus.backend.api.SeqSetCitingSequence import org.loculus.backend.api.SeqSetRecord +import org.loculus.backend.api.SequenceCitation import org.loculus.backend.api.Status.APPROVED_FOR_RELEASE import org.loculus.backend.api.SubmittedSeqSetRecord import org.loculus.backend.auth.AuthenticatedUser @@ -531,6 +538,53 @@ class SeqSetCitationsDatabaseService( } } + fun getSequenceCitations(accession: String, version: Long?): List { + log.info { "Get sequence citations for accession ${accession}${version?.let { ", version $it" } ?: ""}" } + + // If a version is provided, return citations pinned to that exact accession and version + // Otherwise, return all citations for the accession, regardless of version, including unversioned + // NOTE: In this table, the 'accession' column can contain both accessions and accessionVersions + val accessionQuery: Op = if (version != null) { + val accessionVersion = AccessionVersion(accession, version) + SeqSetRecordsTable.accession eq accessionVersion.displayAccessionVersion() + } else { + (SeqSetRecordsTable.accession eq accession) or + (SeqSetRecordsTable.accession like (LikePattern.ofLiteral("$accession.") + "%")) + } + + return SeqSetCitationSourceTable.innerJoin( + SeqSetToCitationSourceTable, + ).innerJoin( + SeqSetsTable, + ).innerJoin(SeqSetToRecordsTable).innerJoin(SeqSetRecordsTable).selectAll() + .where { accessionQuery } + .orderBy( + SeqSetCitationSourceTable.year to SortOrder.DESC, + SeqSetCitationSourceTable.citationSourceId to SortOrder.DESC, + ) + .groupBy { it[SeqSetCitationSourceTable.citationSourceId] } + .map { (_, rows) -> + val first = rows.first() + SequenceCitation( + source = CitationSource( + sourceDOI = first[SeqSetCitationSourceTable.sourceDOI], + title = first[SeqSetCitationSourceTable.title], + year = first[SeqSetCitationSourceTable.year], + contributors = first[SeqSetCitationSourceTable.contributors], + ), + seqSets = rows.map { + SeqSetCitingSequence( + seqSetAccessionVersion = AccessionVersion( + it[SeqSetsTable.seqSetId], + it[SeqSetsTable.seqSetVersion], + ).displayAccessionVersion(), + sequenceAccession = it[SeqSetRecordsTable.accession], + ) + }, + ) + } + } + fun validateSeqSetRecords(seqSetRecords: List) { if (seqSetRecords.isEmpty()) { throw UnprocessableEntityException( diff --git a/backend/src/test/kotlin/org/loculus/backend/controller/seqsetcitations/CitationEndpointsTest.kt b/backend/src/test/kotlin/org/loculus/backend/controller/seqsetcitations/CitationEndpointsTest.kt index 0684d3c15a..14fe5bbcfa 100644 --- a/backend/src/test/kotlin/org/loculus/backend/controller/seqsetcitations/CitationEndpointsTest.kt +++ b/backend/src/test/kotlin/org/loculus/backend/controller/seqsetcitations/CitationEndpointsTest.kt @@ -4,6 +4,7 @@ import com.jayway.jsonpath.JsonPath import com.ninjasquad.springmockk.MockkBean import io.mockk.every import org.hamcrest.CoreMatchers.containsString +import org.hamcrest.Matchers.containsInAnyOrder import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.params.ParameterizedTest @@ -192,6 +193,41 @@ class CitationEndpointsTest( ) } + @ParameterizedTest(name = "{0}") + @MethodSource("sequenceCitationVersionCases") + fun `WHEN getting sequence citations`(case: SequenceCitationVersionCase) { + val citations = case.citationsMap.map { (accession, citingSourceDOI) -> + val seqSetRecords = """[{ "accession": "$accession", "type": "loculus" }]""" + val result = client.createSeqSet(seqSetRecords = seqSetRecords).andExpect(status().isOk).andReturn() + val seqSetId = JsonPath.read(result.response.contentAsString, "$.seqSetId") + val seqSetVersion = JsonPath.read(result.response.contentAsString, "$.seqSetVersion").toLong() + client.createSeqSetDOI(seqSetId = seqSetId, seqSetVersion = seqSetVersion).andExpect(status().isOk) + SeqSetCitationSource( + CitationSource( + sourceDOI = citingSourceDOI, + title = "A paper citing $accession", + year = 2024, + contributors = listOf(CitationContributor(givenName = "Jane", surname = "Doe")), + ), + seqSetDOIs = setOf("$MOCK_DOI_PREFIX/$seqSetId.$seqSetVersion"), + ) + } + + every { crossRefService.isActive } returns true + every { crossRefService.getCrossRefCitedBy(MOCK_DOI_PREFIX) } returns + CrossRefCitedByResult(citations, emptyList()) + seqSetCrossRefCitationsTask.task() + + client.getSequenceCitations(accession = case.accession, version = case.version) + .andExpect(status().isOk) + .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(jsonPath("\$").isArray) + .andExpect(jsonPath("\$.length()").value(case.expectedCitingSourceDOIs.size)) + .andExpect( + jsonPath("\$[*].source.sourceDOI", containsInAnyOrder(*case.expectedCitingSourceDOIs.toTypedArray())), + ) + } + @Test fun `WHEN multiple crossref citation runs link the same citation source THEN all citations are recorded`() { fun createSeqSetWithDOI(): Triple { @@ -250,9 +286,52 @@ class CitationEndpointsTest( val isModifying: Boolean, ) + data class SequenceCitationVersionCase( + val description: String, + val citationsMap: Map, + val accession: String, + val version: Long?, + val expectedCitingSourceDOIs: List, + ) { + override fun toString() = description + } + @JvmStatic fun authorizationTestCases(): List = listOf( Scenario({ jwt, client -> client.getUserCitedBySeqSet(jwt = jwt) }, false), ) + + @JvmStatic + fun sequenceCitationVersionCases(): List { + // Map of test accessions to citation source DOIs + val testAccessions = listOf( + MOCK_SEQ_ACCESSION, + "$MOCK_SEQ_ACCESSION.1", + "$MOCK_SEQ_ACCESSION.2", + "$MOCK_SEQ_ACCESSION-other.1", + ) + val citationsMap = testAccessions.associateWith { "10.5678/paper-citing-$it" } + + return listOf( + SequenceCitationVersionCase( + description = "accession and version THEN returns citations for the exact accession version", + citationsMap = citationsMap, + accession = MOCK_SEQ_ACCESSION, + version = 1L, + expectedCitingSourceDOIs = listOf("10.5678/paper-citing-${MOCK_SEQ_ACCESSION}.1"), + ), + SequenceCitationVersionCase( + description = "accession only THEN returns citations for all accession versions + unversioned", + citationsMap = citationsMap, + accession = MOCK_SEQ_ACCESSION, + version = null, + expectedCitingSourceDOIs = listOf( + "10.5678/paper-citing-${MOCK_SEQ_ACCESSION}", + "10.5678/paper-citing-${MOCK_SEQ_ACCESSION}.1", + "10.5678/paper-citing-${MOCK_SEQ_ACCESSION}.2", + ), + ), + ) + } } } diff --git a/backend/src/test/kotlin/org/loculus/backend/controller/seqsetcitations/SeqSetCitationsControllerClient.kt b/backend/src/test/kotlin/org/loculus/backend/controller/seqsetcitations/SeqSetCitationsControllerClient.kt index 941099175d..a35bbf5047 100644 --- a/backend/src/test/kotlin/org/loculus/backend/controller/seqsetcitations/SeqSetCitationsControllerClient.kt +++ b/backend/src/test/kotlin/org/loculus/backend/controller/seqsetcitations/SeqSetCitationsControllerClient.kt @@ -128,6 +128,13 @@ class SeqSetCitationsControllerClient(private val mockMvc: MockMvc) { .param("version", seqSetVersion.toString()), ) + fun getSequenceCitations(accession: String = MOCK_SEQ_ACCESSION, version: Long? = MOCK_SEQ_VERSION): ResultActions = + mockMvc.perform( + get("/get-sequence-citations") + .param("accession", accession) + .apply { version?.let { param("version", it.toString()) } }, + ) + fun getAuthor(username: String): ResultActions = mockMvc.perform( get("/get-author") .param("username", username), diff --git a/backend/src/test/kotlin/org/loculus/backend/service/crossref/CrossRefServiceTest.kt b/backend/src/test/kotlin/org/loculus/backend/service/crossref/CrossRefServiceTest.kt index 5210237f5c..68d99c15bf 100644 --- a/backend/src/test/kotlin/org/loculus/backend/service/crossref/CrossRefServiceTest.kt +++ b/backend/src/test/kotlin/org/loculus/backend/service/crossref/CrossRefServiceTest.kt @@ -326,7 +326,9 @@ class CrossRefServiceTest( val description: String, val xml: String, val expectedReasonContains: String, - ) + ) { + override fun toString() = description + } @JvmStatic fun crossRefValidationErrorCases(): List = listOf( diff --git a/website/src/components/SeqSetCitations/CitationList.tsx b/website/src/components/SeqSetCitations/CitationList.tsx new file mode 100644 index 0000000000..754aaee0fd --- /dev/null +++ b/website/src/components/SeqSetCitations/CitationList.tsx @@ -0,0 +1,56 @@ +import { useState, type FC } from 'react'; + +import { CitationDetails, CitationTable } from './CitationTable'; +import type { SeqSetCitation, SequenceCitation } from '../../types/seqSetCitation'; +import { BaseDialog } from '../common/BaseDialog'; +import { Button } from '../common/Button'; + +interface CitationListProps { + citations: SeqSetCitation[] | SequenceCitation[]; + maxDisplayedCitations?: number; + modalTitle?: string; +} + +const CitationList: FC = ({ citations, maxDisplayedCitations, modalTitle }) => { + const [isOpen, setIsOpen] = useState(false); + const displayCitationsModalButton = maxDisplayedCitations !== undefined && citations.length > maxDisplayedCitations; + + return ( +
+ setIsOpen(false)} + title={modalTitle ?? 'Citations'} + fullWidth={false} + className='min-h-[60vh]' + > + + + {citations.length > 0 ? ( +
+
    + {(maxDisplayedCitations !== undefined + ? citations.slice(0, maxDisplayedCitations) + : citations + ).map((citation: SeqSetCitation | SequenceCitation) => ( +
  • + +
  • + ))} +
+ {displayCitationsModalButton && ( + + )} +
+ ) : ( +
+ No citations found. +
+ )} +
+ ); +}; + +export default CitationList; diff --git a/website/src/components/SeqSetCitations/CitationTable.tsx b/website/src/components/SeqSetCitations/CitationTable.tsx index fd697aba43..9114ca66c9 100644 --- a/website/src/components/SeqSetCitations/CitationTable.tsx +++ b/website/src/components/SeqSetCitations/CitationTable.tsx @@ -1,38 +1,62 @@ import { type FC } from 'react'; -import { type SeqSetCitation } from '../../types/seqSetCitation'; +import { routes } from '../../routes/routes'; +import { type SeqSetCitation, type SequenceCitation } from '../../types/seqSetCitation'; interface CitationTableProps { isLoading: boolean; error: Error | null; - citations: SeqSetCitation[]; + citations: SeqSetCitation[] | SequenceCitation[]; } interface CitationRowProps { - citation: SeqSetCitation; + citation: SeqSetCitation | SequenceCitation; } +export const CitationDetails: FC<{ + citation: SeqSetCitation | SequenceCitation; + className?: string; + displayYear?: boolean; +}> = ({ citation, className = '', displayYear = false }) => { + return ( +
+ + {citation.source.title} + {displayYear && ` (${citation.source.year})`} + +
+ {citation.source.contributors + .map((contributor) => [contributor.givenName, contributor.surname].filter((name) => name).join(' ')) + .join(', ')} +
+ {'seqSets' in citation && citation.seqSets.length > 0 && ( + + From SeqSet{citation.seqSets.length > 1 ? 's' : ''}: + {citation.seqSets.map((seqSet, i) => ( + + + {seqSet.seqSetAccessionVersion} + + (references {seqSet.sequenceAccession}) + {i < citation.seqSets.length - 1 && ,} + + ))} + + )} +
+ ); +}; + const CitationRow: FC = ({ citation }) => { return ( -
- - {citation.source.title} - -
- {citation.source.contributors - .map((contributor) => - [contributor.givenName, contributor.surname].filter((name) => name).join(' '), - ) - .join(', ')} -
-
+ {citation.source.year} diff --git a/website/src/components/SequenceDetailsPage/DataTable.tsx b/website/src/components/SequenceDetailsPage/DataTable.tsx index 3410a6633b..e434f4993f 100644 --- a/website/src/components/SequenceDetailsPage/DataTable.tsx +++ b/website/src/components/SequenceDetailsPage/DataTable.tsx @@ -7,11 +7,13 @@ import { type TableDataEntry } from './types'; import { type DataUseTermsHistoryEntry } from '../../types/backend'; import { DEFAULT_AA_MUTATION_DETAILS_HEADER, DEFAULT_NUC_MUTATION_DETAILS_HEADER } from '../../types/config'; import { type ReferenceAccession, type ReferenceGenomesInfo } from '../../types/referencesGenomes'; +import type { SequenceCitation } from '../../types/seqSetCitation'; import { deduplicateSemicolonSeparated } from '../../utils/deduplicateSemicolonSeparated'; import { getInsdcAccessionsFromSegmentReferences, type SegmentReferenceSelections, } from '../../utils/sequenceTypeHelpers'; +import CitationList from '../SeqSetCitations/CitationList'; import AkarInfo from '~icons/ri/information-line'; interface Props { @@ -19,6 +21,7 @@ interface Props { dataUseTermsHistory: DataUseTermsHistoryEntry[]; referenceGenomesInfo: ReferenceGenomesInfo; segmentReferences?: SegmentReferenceSelections; + sequenceCitations?: SequenceCitation[]; } const ReferenceDisplay = ({ reference }: { reference: ReferenceAccession[] }) => { @@ -42,6 +45,7 @@ const DataTableComponent: React.FC = ({ dataUseTermsHistory, referenceGenomesInfo, segmentReferences, + sequenceCitations, }) => { const references = getInsdcAccessionsFromSegmentReferences(referenceGenomesInfo, segmentReferences); const hasReferenceAccession = references.filter((item) => item.insdcAccessionFull !== undefined).length > 0; @@ -58,6 +62,8 @@ const DataTableComponent: React.FC = ({ const mutationSections = dataTableData.table.filter( ({ header }) => header === DEFAULT_NUC_MUTATION_DETAILS_HEADER || header === DEFAULT_AA_MUTATION_DETAILS_HEADER, ); + const hasSequenceCitations = sequenceCitations !== undefined && sequenceCitations.length > 0; + return (
{dataTableData.topmatter.sequenceDisplayName !== undefined && ( @@ -78,7 +84,7 @@ const DataTableComponent: React.FC = ({
)} - {generalSections.length > 0 && ( + {(generalSections.length > 0 || hasSequenceCitations) && (
= ({
))} + {hasSequenceCitations && ( +
+
+

Cited in

+
+
+ +
+
+ )} )} diff --git a/website/src/components/SequenceDetailsPage/SequenceDataUI.tsx b/website/src/components/SequenceDetailsPage/SequenceDataUI.tsx index afc761beb1..ce7bbbdcf7 100644 --- a/website/src/components/SequenceDetailsPage/SequenceDataUI.tsx +++ b/website/src/components/SequenceDetailsPage/SequenceDataUI.tsx @@ -12,6 +12,7 @@ import { type DataUseTermsHistoryEntry, type Group, type RestrictedDataUseTerms import { type Schema, type SequenceFlaggingConfig } from '../../types/config'; import { type ReferenceGenomesInfo } from '../../types/referencesGenomes'; import { type ClientConfig } from '../../types/runtimeConfig'; +import { type SequenceCitation } from '../../types/seqSetCitation.ts'; import type { SegmentReferenceSelections } from '../../utils/sequenceTypeHelpers.ts'; import { EditDataUseTermsButton } from '../DataUseTerms/EditDataUseTermsButton'; import { Button } from '../common/Button'; @@ -30,6 +31,7 @@ interface Props { accessToken: string | undefined; sequenceFlaggingConfig: SequenceFlaggingConfig | undefined; referenceGenomesInfo: ReferenceGenomesInfo; + sequenceCitations?: SequenceCitation[]; isRevocation?: boolean; onRevokeSuccess?: () => void; } @@ -46,6 +48,7 @@ export const SequenceDataUI: FC = ({ accessToken, sequenceFlaggingConfig, referenceGenomesInfo, + sequenceCitations, isRevocation, onRevokeSuccess, }: Props) => { @@ -73,6 +76,7 @@ export const SequenceDataUI: FC = ({ segmentReferences={segmentReferences} dataUseTermsHistory={dataUseTermsHistory} referenceGenomesInfo={referenceGenomesInfo} + sequenceCitations={sequenceCitations} /> {schema.submissionDataTypes.consensusSequences && !isRevocation && (
diff --git a/website/src/pages/seq/[accessionVersion]/details.json.ts b/website/src/pages/seq/[accessionVersion]/details.json.ts index 9b1b34fbeb..0729db9798 100644 --- a/website/src/pages/seq/[accessionVersion]/details.json.ts +++ b/website/src/pages/seq/[accessionVersion]/details.json.ts @@ -2,15 +2,25 @@ import { type APIRoute } from 'astro'; import { findOrganismAndData } from './findOrganismAndData'; import { SequenceDetailsTableResultType } from './getSequenceDetailsTableData'; -import { getRuntimeConfig, getSchema } from '../../../config'; +import { getRuntimeConfig, getSchema, seqSetsAreEnabled } from '../../../config'; import { getInstanceLogger } from '../../../logger.ts'; +import { SeqSetCitationClient } from '../../../services/seqSetCitationClient.ts'; import type { DetailsJson } from '../../../types/detailsJson'; +import { parseAccessionVersionFromString } from '../../../utils/extractAccessionVersion.ts'; const logger = getInstanceLogger('details.json'); export const GET: APIRoute = async (req) => { const params = req.params as { accessionVersion: string; accessToken?: string }; const { accessionVersion } = params; + const { accession } = parseAccessionVersionFromString(accessionVersion); + + const sequenceCitationsPromise = seqSetsAreEnabled() + ? SeqSetCitationClient.create().call('getSequenceCitations', { + params: { accession }, // Display citations across all accession versions + }) + : undefined; + const sequenceDetailsTableData = await findOrganismAndData(accessionVersion); if (sequenceDetailsTableData.isErr()) { @@ -30,6 +40,8 @@ export const GET: APIRoute = async (req) => { }); } + const sequenceCitations = (await sequenceCitationsPromise)?.unwrapOr(undefined); + const clientConfig = getRuntimeConfig().public; const schema = getSchema(organism); @@ -44,6 +56,7 @@ export const GET: APIRoute = async (req) => { segmentReferences: result.segmentReferences, isRevocation: result.isRevocation, sequenceEntryHistory: result.sequenceEntryHistory, + sequenceCitations, }; return new Response(JSON.stringify(detailsDataUIProps), { diff --git a/website/src/pages/seq/[accessionVersion]/index.astro b/website/src/pages/seq/[accessionVersion]/index.astro index 5bf2c72664..26e18c890e 100644 --- a/website/src/pages/seq/[accessionVersion]/index.astro +++ b/website/src/pages/seq/[accessionVersion]/index.astro @@ -6,15 +6,23 @@ import { SequenceDataUI } from '../../../components/SequenceDetailsPage/Sequence import SequencesBanner from '../../../components/SequenceDetailsPage/SequencesBanner.tsx'; import SequencesDataTableTitle from '../../../components/SequenceDetailsPage/SequencesDataTableTitle.astro'; import ErrorBox from '../../../components/common/ErrorBox.tsx'; -import { getSchema, getRuntimeConfig, getReferenceGenomes } from '../../../config'; -import { getWebsiteConfig } from '../../../config'; +import { getSchema, getRuntimeConfig, getWebsiteConfig, getReferenceGenomes, seqSetsAreEnabled } from '../../../config'; import BaseLayout from '../../../layouts/BaseLayout.astro'; +import { SeqSetCitationClient } from '../../../services/seqSetCitationClient'; import { type Group } from '../../../types/backend'; +import { parseAccessionVersionFromString } from '../../../utils/extractAccessionVersion.ts'; import { getAccessToken } from '../../../utils/getAccessToken'; import { getMyGroups } from '../../../utils/getMyGroups'; import { getSegmentNames } from '../../../utils/sequenceTypeHelpers'; const accessionVersion = Astro.params.accessionVersion!; +const { accession } = parseAccessionVersionFromString(accessionVersion); + +const sequenceCitationsPromise = seqSetsAreEnabled() + ? SeqSetCitationClient.create().call('getSequenceCitations', { + params: { accession }, // Display citations across all accession versions + }) + : undefined; const sequenceDetailsTableData = await findOrganismAndData(accessionVersion); @@ -25,6 +33,8 @@ if ( return Astro.redirect(sequenceDetailsTableData.value.result.redirectUrl); } +const sequenceCitations = (await sequenceCitationsPromise)?.unwrapOr(undefined); + const session = Astro.locals.session; const accessToken = getAccessToken(session); const clientConfig = getRuntimeConfig().public; @@ -93,6 +103,7 @@ const sequenceFlaggingConfig = getWebsiteConfig().sequenceFlagging; myGroups={myGroups} accessToken={accessToken} sequenceFlaggingConfig={showDownloadAndReport ? sequenceFlaggingConfig : undefined} + sequenceCitations={sequenceCitations} client:load /> ))} diff --git a/website/src/services/seqSetCitationApi.ts b/website/src/services/seqSetCitationApi.ts index 6bb7452fd9..a04fa501d9 100644 --- a/website/src/services/seqSetCitationApi.ts +++ b/website/src/services/seqSetCitationApi.ts @@ -2,7 +2,14 @@ import { makeApi, makeEndpoint } from '@zodios/core'; import z from 'zod'; import { authorizationHeader, notAuthorizedError } from './commonApiTypes.ts'; -import { authorProfile, seqSets, seqSetRecords, citedByResult, seqSetCitations } from '../types/seqSetCitation.ts'; +import { + authorProfile, + seqSets, + seqSetRecords, + citedByResult, + seqSetCitations, + sequenceCitations, +} from '../types/seqSetCitation.ts'; const getSeqSetsOfUserEndpoint = makeEndpoint({ method: 'get', @@ -30,6 +37,21 @@ const getSeqSetCitationsEndpoint = makeEndpoint({ errors: [notAuthorizedError], }); +const getSequenceCitationsEndpoint = makeEndpoint({ + method: 'get', + path: '/get-sequence-citations?accession=:accession', + parameters: [ + { + name: 'version', + type: 'Query', + schema: z.number().optional(), + }, + ], + alias: 'getSequenceCitations', + response: sequenceCitations, + errors: [notAuthorizedError], +}); + const getSeqSetEndpoint = makeEndpoint({ method: 'get', path: '/get-seqset?seqSetId=:seqSetId&version=:version', @@ -174,6 +196,7 @@ export const seqSetCitationApi = makeApi([ getSeqSetsOfUserEndpoint, getUserCitedByEndpoint, getSeqSetCitationsEndpoint, + getSequenceCitationsEndpoint, getSeqSetEndpoint, getSeqSetVersionsEndpoint, getSeqSetRecordsEndpoint, diff --git a/website/src/types/detailsJson.ts b/website/src/types/detailsJson.ts index dbd59e4366..26ee6618b1 100644 --- a/website/src/types/detailsJson.ts +++ b/website/src/types/detailsJson.ts @@ -4,6 +4,7 @@ import { dataUseTermsHistoryEntry } from './backend.ts'; import { schema } from './config.ts'; import { parsedSequenceEntryHistoryEntrySchema } from './lapis.ts'; import { serviceUrls } from './runtimeConfig.ts'; +import { sequenceCitations } from './seqSetCitation.ts'; import { tableDataEntrySchema } from '../components/SequenceDetailsPage/types.ts'; export const detailsJsonSchema = z.object({ @@ -17,6 +18,7 @@ export const detailsJsonSchema = z.object({ segmentReferences: z.record(z.string(), z.string().nullable()).optional(), isRevocation: z.boolean(), sequenceEntryHistory: z.array(parsedSequenceEntryHistoryEntrySchema), + sequenceCitations: sequenceCitations.optional(), }); export type DetailsJson = z.infer; diff --git a/website/src/types/seqSetCitation.ts b/website/src/types/seqSetCitation.ts index ef2904792c..ea335d0cb6 100644 --- a/website/src/types/seqSetCitation.ts +++ b/website/src/types/seqSetCitation.ts @@ -57,3 +57,17 @@ const seqSetCitation = z.object({ export type SeqSetCitation = z.infer; export const seqSetCitations = z.array(seqSetCitation); + +const sequenceCitation = z.object({ + source: citationSource, + seqSets: z.array( + z.object({ + seqSetAccessionVersion: z.string(), + // can be either a bare accession or an accessionVersion + sequenceAccession: z.string(), + }), + ), +}); +export type SequenceCitation = z.infer; + +export const sequenceCitations = z.array(sequenceCitation);